Compare commits
7 Commits
79d57da769
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da5f7f10d | ||
|
|
cc939b6298 | ||
|
|
e3bda469bb | ||
|
|
2ef58a44b8 | ||
|
|
2d9f26a6f0 | ||
|
|
501f548bcc | ||
|
|
cc8af846d5 |
@@ -52,20 +52,45 @@ async def retry_interaction(record_id: int, db=Depends(get_db)):
|
||||
if not user or user.status != 2:
|
||||
raise HTTPException(status_code=400, detail="用户未登录,无法重试")
|
||||
|
||||
success, err = False, "未知类型"
|
||||
success, err, platform_record_id = False, "未知类型", ""
|
||||
if record.interact_type == "comment" and record.content:
|
||||
success, err = await news_service.post_comment(db, user, record.article_id, record.article_title or "", record.content)
|
||||
success, err, platform_record_id = await news_service.post_comment_with_record_id(
|
||||
db, user, record.article_id, record.article_title or "", record.content
|
||||
)
|
||||
elif record.interact_type == "like":
|
||||
success, err = await news_service.like_news(db, user, record.article_id, org_id="", title=record.article_title or "")
|
||||
elif record.interact_type == "collect":
|
||||
success, err = await news_service.collect_news(db, user, record.article_id, title=record.article_title or "")
|
||||
elif record.interact_type == "forward":
|
||||
success, err = await news_service.forward_news(db, user, record.article_id)
|
||||
elif record.interact_type == "reply" and record.content:
|
||||
parent_comment = None
|
||||
if record.parent_comment_id:
|
||||
comments = await news_service.get_comments(db, user, record.article_id or "")
|
||||
parent_comment = next(
|
||||
(
|
||||
c for c in comments
|
||||
if str(c.get("id") or c.get("commentId") or "") == str(record.parent_comment_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not parent_comment:
|
||||
success, err, platform_record_id = False, "未找到被回复的原评论,无法重试回复", ""
|
||||
else:
|
||||
success, err, platform_record_id = await news_service.post_reply_with_record_id(
|
||||
db, user,
|
||||
record.article_id or "",
|
||||
record.parent_comment_id or "",
|
||||
record.content,
|
||||
parent_comment=parent_comment,
|
||||
article_title=record.article_title or "",
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=1 if success else 2,
|
||||
error_msg=None if success else err,
|
||||
platform_record_id=platform_record_id or record.platform_record_id,
|
||||
retry_count=record.retry_count + 1,
|
||||
)
|
||||
)
|
||||
@@ -147,15 +172,69 @@ async def cancel_interaction(record_id: int, db=Depends(get_db)):
|
||||
news_id=record.article_id or "",
|
||||
title=record.article_title or "",
|
||||
)
|
||||
elif record.interact_type in ("comment", "reply"):
|
||||
elif record.interact_type == "comment":
|
||||
comment_id = record.platform_record_id or ""
|
||||
if not comment_id:
|
||||
return ApiResponse(code=400, message="缺少评论ID,无法删除")
|
||||
comment_id, comment_count = await news_service.find_comment_id(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
content=record.content or "",
|
||||
)
|
||||
if comment_id:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
platform_record_id=comment_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
elif comment_count == 0:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=3,
|
||||
error_msg="平台未查询到评论,已标记取消",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="平台未查询到评论,已标记取消")
|
||||
else:
|
||||
return ApiResponse(code=400, message="缺少评论ID,且未能从平台评论列表反查到该评论")
|
||||
ok, err = await news_service.cancel_comment(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
comment_id=comment_id,
|
||||
)
|
||||
elif record.interact_type == "reply":
|
||||
reply_id = record.platform_record_id or ""
|
||||
if not reply_id:
|
||||
reply_id, reply_count = await news_service.find_reply_id(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
content=record.content or "",
|
||||
parent_comment_id=record.parent_comment_id or "",
|
||||
)
|
||||
if reply_id:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
platform_record_id=reply_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
elif reply_count == 0:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=3,
|
||||
error_msg="平台未查询到回复,已标记取消",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="平台未查询到回复,已标记取消")
|
||||
else:
|
||||
return ApiResponse(code=400, message="缺少回复ID,且未能从平台回复列表反查到该回复")
|
||||
ok, err = await news_service.cancel_reply(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
reply_id=reply_id,
|
||||
)
|
||||
|
||||
if ok:
|
||||
# 更新状态为手动取消(status=3)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""虚拟用户管理接口"""
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import mimetypes
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import io
|
||||
@@ -9,6 +12,21 @@ from app.schemas import ApiResponse, UserCreateRequest, UserUpdateRequest, UserB
|
||||
from app.services.user_service import user_service
|
||||
|
||||
router = APIRouter()
|
||||
_UPLOADS_DIR = Path(__file__).resolve().parents[2] / "uploads" / "avatars"
|
||||
_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def _save_local_avatar(file_bytes: bytes, filename: str, content_type: str | None) -> str:
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
guessed_ext = mimetypes.guess_extension(content_type or "") or ".jpg"
|
||||
ext = ".jpg" if guessed_ext == ".jpe" else guessed_ext
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
ext = ".jpg"
|
||||
|
||||
target = _UPLOADS_DIR / f"{uuid.uuid4().hex}{ext}"
|
||||
target.write_bytes(file_bytes)
|
||||
return f"/api/uploads/avatars/{target.name}"
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -268,13 +286,20 @@ async def sync_all_profiles(db=Depends(get_db)):
|
||||
sess = await get_session(uid)
|
||||
if not sess:
|
||||
return False
|
||||
ur = await s.execute(select(_VU).where(_VU.id == uid))
|
||||
user = ur.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
platform_uid = sess.get("platform_uid", "")
|
||||
# 登录成功时 session 里已存有用户信息
|
||||
vals = {}
|
||||
if platform_uid: vals["platform_uid"] = platform_uid
|
||||
# session 里的字段(登录时写入)
|
||||
if sess.get("nickname"): vals["nickname"] = sess["nickname"]
|
||||
if sess.get("real_name"): vals["real_name"] = sess["real_name"]
|
||||
sync_nickname = sess.get("nickname", "")
|
||||
sync_real_name = sess.get("real_name", "")
|
||||
resolved_nickname = news_service._resolve_synced_nickname(user, sync_nickname, sync_real_name)
|
||||
if resolved_nickname: vals["nickname"] = resolved_nickname
|
||||
if sync_real_name: vals["real_name"] = sync_real_name
|
||||
if sess.get("sex"): vals["sex"] = int(sess["sex"])
|
||||
if sess.get("avatar"): vals["avatar_url"] = sess["avatar"]
|
||||
if vals:
|
||||
@@ -327,9 +352,8 @@ async def upload_avatar(
|
||||
else:
|
||||
return ApiResponse(code=500, message=f"头像上传到平台失败: {result}")
|
||||
else:
|
||||
# 仅本地存储(转 base64 或存储到本地)
|
||||
import base64
|
||||
avatar_url = f"data:{file.content_type};base64,{base64.b64encode(file_bytes).decode()}"
|
||||
# 未登录用户本地落盘,避免 base64 超过 avatar_url 字段长度
|
||||
avatar_url = await _save_local_avatar(file_bytes, file.filename or "", file.content_type)
|
||||
|
||||
# 更新数据库
|
||||
await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url))
|
||||
|
||||
@@ -62,7 +62,7 @@ async def init_db():
|
||||
# 导入所有模型类,确保 SQLAlchemy ORM 元数据注册
|
||||
from app.models import (
|
||||
VirtualUser, UserPersonality, InteractionRecord,
|
||||
TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
)
|
||||
logger.info("✅ 数据库模型注册成功")
|
||||
logger.info("✅ 数据库初始化完成")
|
||||
|
||||
@@ -6,7 +6,9 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import re as _re
|
||||
from pathlib import Path
|
||||
|
||||
# 修复 Pydantic v2 把 naive datetime 序列化为 +00:00 的问题
|
||||
# 数据库存的是北京时间,应标记为 +08:00
|
||||
@@ -89,6 +91,10 @@ app.add_middleware(
|
||||
# 注册路由
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
uploads_dir = Path(__file__).resolve().parent / "uploads"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/api/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
||||
@@ -75,6 +75,33 @@ class InteractionRecord(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class PendingReplyTask(Base):
|
||||
__tablename__ = "pending_reply_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
actor_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
next_actor_user_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
||||
news_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
news_title: Mapped[str | None] = mapped_column(String(256))
|
||||
article_org_id: Mapped[str | None] = mapped_column(String(64))
|
||||
parent_comment_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
parent_comment: Mapped[dict | None] = mapped_column(JSON)
|
||||
reply_to: Mapped[dict | None] = mapped_column(JSON)
|
||||
root_content: Mapped[str | None] = mapped_column(Text)
|
||||
context: Mapped[str | None] = mapped_column(Text)
|
||||
next_probability: Mapped[float] = mapped_column(Float, default=0.3)
|
||||
next_delay_min_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
next_delay_max_seconds: Mapped[int] = mapped_column(Integer, default=7200)
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0, index=True) # 0待发送 1发送中 2已发送 3失败
|
||||
attempts: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
last_error: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TokenStat(Base):
|
||||
__tablename__ = "token_stats"
|
||||
|
||||
|
||||
@@ -190,6 +190,32 @@ class AIService:
|
||||
请对上面的评论写一条友善自然的回复,{word_min}~{word_max}字,直接输出回复内容。"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=150)
|
||||
|
||||
async def generate_thread_reply(
|
||||
self, db: AsyncSession, article_title: str, root_comment: str,
|
||||
reply_context: str, personality_prompt: str,
|
||||
word_min: int = 15, word_max: int = 60
|
||||
) -> tuple[str, int]:
|
||||
"""结合文章、原评论和上下文生成评论回复链中的下一句。"""
|
||||
system_prompt = f"""你是一名真实的社区用户。{personality_prompt}
|
||||
|
||||
重要规则:
|
||||
- 回复必须积极正面、文明友善,不含任何敏感违规内容
|
||||
- 要自然接住对方的话,不要机械复述
|
||||
- 不要透露自己是AI或虚拟用户"""
|
||||
prompt = f"""文章标题:{article_title}
|
||||
原评论:{root_comment}
|
||||
当前对话上下文:{reply_context}
|
||||
|
||||
请结合文章、原评论和当前对话,写一条自然的后续回复。
|
||||
要求:
|
||||
1. 字数 {word_min}~{word_max} 字
|
||||
2. 语气像真实用户交流,可以认同、补充或追问
|
||||
3. 必须围绕文章和评论内容,不要跑题
|
||||
4. 只输出回复正文,不要加任何前缀或解释
|
||||
|
||||
回复:"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=180)
|
||||
|
||||
async def test_model(self, db: AsyncSession, model_id: int, test_prompt: str) -> dict:
|
||||
"""测试模型可用性"""
|
||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||
|
||||
@@ -7,7 +7,7 @@ import uuid
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
@@ -45,6 +45,31 @@ class NewsPlatformService:
|
||||
"orgId": await self._cfg(db, "platform_org_id", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _api_root(base_url: str) -> str:
|
||||
if "/api/" in base_url:
|
||||
return base_url.split("/api/", 1)[0] + "/api"
|
||||
return base_url.rstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_synced_nickname(user: VirtualUser, sync_nickname: str, sync_real_name: str) -> str:
|
||||
"""避免登录同步把有意义的本地昵称覆盖回手机号。"""
|
||||
current_nickname = (user.nickname or "").strip()
|
||||
current_real_name = (user.real_name or "").strip()
|
||||
sync_nickname = (sync_nickname or "").strip()
|
||||
sync_real_name = (sync_real_name or "").strip()
|
||||
account = (user.account or "").strip()
|
||||
|
||||
if current_nickname and current_nickname != account:
|
||||
return current_nickname
|
||||
if sync_nickname and sync_nickname != account:
|
||||
return sync_nickname
|
||||
if sync_real_name:
|
||||
return sync_real_name
|
||||
if current_real_name:
|
||||
return current_real_name
|
||||
return sync_nickname or current_nickname or account
|
||||
|
||||
# ─── 签名(完全对应 sign.js 逻辑) ─────────────────────────
|
||||
@staticmethod
|
||||
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
|
||||
@@ -188,6 +213,7 @@ class NewsPlatformService:
|
||||
sync_real_name = user_info.get("realName") or ""
|
||||
sync_sex = int(user_info.get("sex") or 0)
|
||||
sync_avatar = user_info.get("avatar") or ""
|
||||
preferred_nickname = self._resolve_synced_nickname(user, sync_nickname, sync_real_name)
|
||||
|
||||
await set_session(user.id, {
|
||||
"token": access_token,
|
||||
@@ -196,7 +222,7 @@ class NewsPlatformService:
|
||||
"org_id": org_id or cfg.get("orgId", ""),
|
||||
"login_time": datetime.now().isoformat(),
|
||||
# 缓存用户信息供 sync 使用
|
||||
"nickname": sync_nickname,
|
||||
"nickname": preferred_nickname,
|
||||
"real_name": sync_real_name,
|
||||
"sex": sync_sex,
|
||||
"avatar": sync_avatar,
|
||||
@@ -209,7 +235,7 @@ class NewsPlatformService:
|
||||
last_login_at=datetime.now(),
|
||||
platform_uid=platform_uid,
|
||||
)
|
||||
if sync_nickname: update_vals["nickname"] = sync_nickname
|
||||
if preferred_nickname: update_vals["nickname"] = preferred_nickname
|
||||
if sync_real_name: update_vals["real_name"] = sync_real_name
|
||||
if sync_sex: update_vals["sex"] = sync_sex
|
||||
if sync_avatar: update_vals["avatar_url"] = sync_avatar
|
||||
@@ -719,51 +745,287 @@ class NewsPlatformService:
|
||||
return False
|
||||
|
||||
async def post_comment(self, db, user, news_id, news_title, content, news_author_id="", org_id="") -> tuple[bool, str]:
|
||||
success, err, _ = await self.post_comment_with_record_id(
|
||||
db, user, news_id, news_title, content,
|
||||
news_author_id=news_author_id, org_id=org_id,
|
||||
)
|
||||
return success, err
|
||||
|
||||
async def post_comment_with_record_id(
|
||||
self, db, user, news_id, news_title, content, news_author_id="", org_id=""
|
||||
) -> tuple[bool, str, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
return False, "未登录", ""
|
||||
biz = await self._biz_url(db)
|
||||
cfg = await self._client(db)
|
||||
uid = sess.get("platform_uid", "")
|
||||
# org_id 优先取文章自带的(从广场数据获取),否则取 session/配置
|
||||
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
|
||||
if not final_org_id:
|
||||
final_org_id = await self._get_article_org_id(db, sess["token"], news_id)
|
||||
body = {
|
||||
"module": "news", "topicId": news_id, "title": news_title,
|
||||
"content": content, "orgId": final_org_id,
|
||||
"toUserId": news_author_id or uid, "userId": uid,
|
||||
"userName": user.nickname, "avatar": user.avatar_url or "",
|
||||
}
|
||||
return await self._json_post(f"{biz}/message/comment", self._bearer(sess["token"]), body)
|
||||
success, err, record_id = await self._json_post_with_record_id(
|
||||
f"{biz}/message/comment", self._bearer(sess["token"]), body
|
||||
)
|
||||
if success and not record_id:
|
||||
record_id, _ = await self.find_comment_id(db, user, news_id, content)
|
||||
return success, err, record_id
|
||||
|
||||
async def post_reply(self, db, user, news_id, comment_id, content) -> tuple[bool, str]:
|
||||
async def post_reply(
|
||||
self, db, user, news_id, comment_id, content,
|
||||
parent_comment: dict | None = None,
|
||||
reply_to: dict | None = None,
|
||||
article_title: str = "",
|
||||
org_id: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
success, err, _ = await self.post_reply_with_record_id(
|
||||
db, user, news_id, comment_id, content,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
article_title=article_title,
|
||||
org_id=org_id,
|
||||
)
|
||||
return success, err
|
||||
|
||||
async def post_reply_with_record_id(
|
||||
self, db, user, news_id, comment_id, content,
|
||||
parent_comment: dict | None = None,
|
||||
reply_to: dict | None = None,
|
||||
article_title: str = "",
|
||||
org_id: str = "",
|
||||
) -> tuple[bool, str, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
return False, "未登录", ""
|
||||
biz = await self._biz_url(db)
|
||||
uid = sess.get("platform_uid", "")
|
||||
cfg = await self._client(db)
|
||||
parent_comment = parent_comment or {}
|
||||
reply_to = reply_to or None
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or comment_id or "")
|
||||
parent_user_id = str(
|
||||
parent_comment.get("createUser")
|
||||
or parent_comment.get("userId")
|
||||
or parent_comment.get("commentUserId")
|
||||
or parent_comment.get("fromUserId")
|
||||
or ""
|
||||
)
|
||||
parent_user_name = (
|
||||
parent_comment.get("userName")
|
||||
or parent_comment.get("fromUserName")
|
||||
or parent_comment.get("nickName")
|
||||
or ""
|
||||
)
|
||||
reply_target = reply_to or parent_comment
|
||||
reply_target_id = str(
|
||||
reply_target.get("id")
|
||||
or reply_target.get("replyId")
|
||||
or reply_target.get("commentId")
|
||||
or parent_comment_id
|
||||
)
|
||||
reply_target_user_id = str(
|
||||
reply_target.get("createUser")
|
||||
or reply_target.get("fromUserId")
|
||||
or reply_target.get("userId")
|
||||
or reply_target.get("commentUserId")
|
||||
or parent_user_id
|
||||
or ""
|
||||
)
|
||||
reply_target_user_name = (
|
||||
reply_target.get("fromUserName")
|
||||
or reply_target.get("userName")
|
||||
or reply_target.get("nickName")
|
||||
or parent_user_name
|
||||
or ""
|
||||
)
|
||||
from_user_name = (
|
||||
(user.real_name or "").strip()
|
||||
or (user.nickname or "").strip()
|
||||
or (user.account or "").strip()
|
||||
)
|
||||
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
|
||||
if not final_org_id:
|
||||
final_org_id = await self._get_article_org_id(db, sess["token"], news_id)
|
||||
body = {
|
||||
"module": "news", "topicId": news_id, "commentId": comment_id,
|
||||
"commentUserId": uid, "content": content,
|
||||
"fromUserName": user.nickname, "avatar": user.avatar_url or "",
|
||||
"module": "news",
|
||||
"modules": "news",
|
||||
"topicId": news_id,
|
||||
"commentId": parent_comment_id,
|
||||
"commentUserId": parent_user_id,
|
||||
"replyId": reply_target_id,
|
||||
"toUserId": reply_target_user_id,
|
||||
"replyType": "2" if reply_to else "1",
|
||||
"toUserName": reply_target_user_name,
|
||||
"content": content,
|
||||
"title": article_title or parent_comment.get("title") or "",
|
||||
"orgId": final_org_id,
|
||||
"fromUserName": from_user_name,
|
||||
"avatar": user.avatar_url or "",
|
||||
}
|
||||
return await self._json_post(f"{biz}/message/comment/reply", self._bearer(sess["token"]), body)
|
||||
success, err, reply_id = await self._json_post_with_record_id(
|
||||
f"{biz}/message/comment/reply", self._bearer(sess["token"]), body
|
||||
)
|
||||
if success and not reply_id:
|
||||
reply_id, _ = await self.find_reply_id(
|
||||
db, user, news_id,
|
||||
content=content,
|
||||
parent_comment_id=parent_comment_id,
|
||||
)
|
||||
return success, err, reply_id
|
||||
|
||||
async def _get_article_org_id(self, db, token: str, news_id: str) -> str:
|
||||
"""从文章详情补齐 H5 评论/回复所需的组织上下文。"""
|
||||
if not news_id:
|
||||
return ""
|
||||
biz = await self._biz_url(db)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as c:
|
||||
r = await c.get(f"{biz}/news/{news_id}", headers=self._bearer(token))
|
||||
if r.status_code != 200:
|
||||
return ""
|
||||
data = (r.json().get("data") or {})
|
||||
return str(
|
||||
data.get("orgId")
|
||||
or data.get("alumnusId")
|
||||
or data.get("publishOrgId")
|
||||
or data.get("initialPublishOrgId")
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
async def get_comments(self, db, user, news_id) -> list:
|
||||
data = await self.get_comments_payload(db, user, news_id)
|
||||
if isinstance(data, dict):
|
||||
rows = data.get("data") or data.get("records") or data.get("list") or data.get("rows")
|
||||
return rows if isinstance(rows, list) else []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def get_comments_payload(self, db, user, news_id) -> Any:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return []
|
||||
biz = await self._biz_url(db)
|
||||
cfg = await self._client(db)
|
||||
try:
|
||||
params = self._build_form({"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}, cfg)
|
||||
params = {"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{biz}/message/comment", headers=self._bearer(sess["token"]), params=params)
|
||||
r = await c.get(
|
||||
f"{self._api_root(biz)}/interaction/open/selectInteractionCommentList",
|
||||
headers=self._bearer(sess["token"]),
|
||||
params=params,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("data", {}).get("data") or []
|
||||
return r.json().get("data") or []
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def find_comment_id(self, db, user, news_id: str, content: str = "") -> tuple[str, int | None]:
|
||||
"""Best-effort lookup for old records created before platform IDs were saved."""
|
||||
sess = await get_session(user.id)
|
||||
my_uid = str(sess.get("platform_uid", "")) if sess else ""
|
||||
payload = await self.get_comments_payload(db, user, news_id)
|
||||
comment_count = None
|
||||
comments = payload if isinstance(payload, list) else []
|
||||
if isinstance(payload, dict):
|
||||
try:
|
||||
comment_count = int(payload.get("commentCount"))
|
||||
except (TypeError, ValueError):
|
||||
comment_count = None
|
||||
comments = (
|
||||
payload.get("data")
|
||||
or payload.get("records")
|
||||
or payload.get("list")
|
||||
or payload.get("rows")
|
||||
or []
|
||||
)
|
||||
if not isinstance(comments, list):
|
||||
comments = []
|
||||
|
||||
target_content = (content or "").strip()
|
||||
for item in comments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
author_id = str(item.get("userId") or item.get("createUser") or item.get("commentUserId") or "")
|
||||
item_content = (item.get("content") or "").strip()
|
||||
if my_uid and author_id and author_id != my_uid:
|
||||
continue
|
||||
if target_content and item_content != target_content:
|
||||
continue
|
||||
comment_id = str(item.get("id") or item.get("commentId") or item.get("messageId") or "")
|
||||
if comment_id:
|
||||
return comment_id, comment_count
|
||||
return "", comment_count
|
||||
|
||||
async def find_reply_id(
|
||||
self, db, user, news_id: str, content: str = "", parent_comment_id: str = ""
|
||||
) -> tuple[str, int | None]:
|
||||
"""Best-effort lookup for reply records created before platform IDs were saved."""
|
||||
sess = await get_session(user.id)
|
||||
my_uid = str(sess.get("platform_uid", "")) if sess else ""
|
||||
payload = await self.get_comments_payload(db, user, news_id)
|
||||
reply_count = None
|
||||
comments = payload if isinstance(payload, list) else []
|
||||
if isinstance(payload, dict):
|
||||
for count_key in ("replyCount", "commentCount", "total"):
|
||||
try:
|
||||
if payload.get(count_key) is not None:
|
||||
reply_count = int(payload.get(count_key))
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
reply_count = None
|
||||
comments = (
|
||||
payload.get("data")
|
||||
or payload.get("records")
|
||||
or payload.get("list")
|
||||
or payload.get("rows")
|
||||
or []
|
||||
)
|
||||
if not isinstance(comments, list):
|
||||
comments = []
|
||||
|
||||
target_content = (content or "").strip()
|
||||
target_parent_id = str(parent_comment_id or "")
|
||||
for item in comments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
current_parent_id = str(item.get("id") or item.get("commentId") or "")
|
||||
if target_parent_id and current_parent_id != target_parent_id:
|
||||
continue
|
||||
replies = (
|
||||
item.get("respList")
|
||||
or item.get("replyList")
|
||||
or item.get("children")
|
||||
or item.get("replies")
|
||||
or []
|
||||
)
|
||||
if not isinstance(replies, list):
|
||||
continue
|
||||
for reply in replies:
|
||||
if not isinstance(reply, dict):
|
||||
continue
|
||||
author_id = str(
|
||||
reply.get("fromUserId")
|
||||
or reply.get("createUser")
|
||||
or reply.get("userId")
|
||||
or reply.get("commentUserId")
|
||||
or ""
|
||||
)
|
||||
reply_content = (reply.get("content") or "").strip()
|
||||
if my_uid and author_id and author_id != my_uid:
|
||||
continue
|
||||
if target_content and reply_content != target_content:
|
||||
continue
|
||||
reply_id = str(reply.get("id") or reply.get("replyId") or reply.get("commentId") or "")
|
||||
if reply_id:
|
||||
return reply_id, reply_count
|
||||
return "", reply_count
|
||||
|
||||
async def like_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
@@ -825,13 +1087,41 @@ class NewsPlatformService:
|
||||
return True, ""
|
||||
return False, f"HTTP {resp.status_code}"
|
||||
|
||||
@staticmethod
|
||||
def _extract_record_id(data: Any) -> str:
|
||||
if data is None:
|
||||
return ""
|
||||
if isinstance(data, (str, int)):
|
||||
return str(data)
|
||||
if isinstance(data, dict):
|
||||
for key in ("id", "replyId", "commentId", "messageId", "recordId"):
|
||||
if data.get(key):
|
||||
return str(data[key])
|
||||
for key in ("data", "record", "reply", "comment", "message"):
|
||||
nested = NewsPlatformService._extract_record_id(data.get(key))
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
|
||||
async def _json_post(self, url, headers, body) -> tuple[bool, str]:
|
||||
success, err, _ = await self._json_post_with_record_id(url, headers, body)
|
||||
return success, err
|
||||
|
||||
async def _json_post_with_record_id(self, url, headers, body) -> tuple[bool, str, str]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.post(url, json=body, headers=headers)
|
||||
return self._ok(r)
|
||||
if r.status_code not in [200, 201]:
|
||||
return False, f"HTTP {r.status_code}", ""
|
||||
try:
|
||||
d = r.json()
|
||||
except Exception:
|
||||
return True, "", ""
|
||||
if d.get("code") in [0, 200]:
|
||||
return True, "", self._extract_record_id(d.get("data"))
|
||||
return False, d.get("message") or "业务失败", ""
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return False, str(e), ""
|
||||
|
||||
async def _write_login_log(self, db, user, action, session_id=None, error_msg=None):
|
||||
try:
|
||||
@@ -898,6 +1188,25 @@ class NewsPlatformService:
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def cancel_reply(self, db, user, news_id: str, reply_id: str) -> tuple[bool, str]:
|
||||
"""DELETE /message/comment/reply/{topicId}/{id} 删除评论回复"""
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
biz = await self._biz_url(db)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.delete(
|
||||
f"{biz}/message/comment/reply/{news_id}/{reply_id}",
|
||||
headers=self._bearer(sess["token"]),
|
||||
)
|
||||
d = r.json()
|
||||
if d.get("code") in [0, 200]:
|
||||
return True, ""
|
||||
return False, d.get("message", "删除回复失败")
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def cancel_collect(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]:
|
||||
"""取消收藏(复用取消点赞接口)"""
|
||||
return await self.cancel_like(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""调度服务 - 定时自动互动、会话校验"""
|
||||
import random
|
||||
import asyncio
|
||||
from datetime import datetime, date
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select, update, func
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.logger import logger
|
||||
from app.models import VirtualUser, UserPersonality, InteractionRecord, SystemConfig
|
||||
from app.models import VirtualUser, UserPersonality, InteractionRecord, PendingReplyTask, SystemConfig
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
@@ -23,6 +23,12 @@ class SchedulerService:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
logger.info("⚡ 立即触发互动任务")
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5"))
|
||||
except (TypeError, ValueError):
|
||||
max_concurrent = 5
|
||||
max_concurrent = max(1, max_concurrent)
|
||||
|
||||
result_r = await session.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.status == 2,
|
||||
@@ -31,13 +37,62 @@ class SchedulerService:
|
||||
)
|
||||
users = result_r.scalars().all()
|
||||
if not users:
|
||||
return {"message": "没有已登录的用户", "triggered": 0}
|
||||
return {
|
||||
"message": "没有已登录的用户",
|
||||
"requested_concurrency": max_concurrent,
|
||||
"attempted_count": 0,
|
||||
"success_count": 0,
|
||||
"skipped_count": 0,
|
||||
"failed_count": 0,
|
||||
"triggered": 0,
|
||||
"users": [],
|
||||
"results": [],
|
||||
}
|
||||
import random
|
||||
selected = random.sample(users, min(5, len(users)))
|
||||
selected = random.sample(users, min(max_concurrent, len(users)))
|
||||
import asyncio
|
||||
tasks = [self._execute_user_interaction(u.id) for u in selected]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return {"triggered": len(selected), "users": [u.account for u in selected]}
|
||||
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
results = []
|
||||
success_count = skipped_count = failed_count = 0
|
||||
for user, outcome in zip(selected, raw_results):
|
||||
if isinstance(outcome, Exception):
|
||||
item = {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "failed",
|
||||
"reason": str(outcome),
|
||||
"interactions": [],
|
||||
}
|
||||
failed_count += 1
|
||||
else:
|
||||
item = outcome or {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_result",
|
||||
"interactions": [],
|
||||
}
|
||||
status = item.get("status")
|
||||
if status == "success":
|
||||
success_count += 1
|
||||
elif status == "failed":
|
||||
failed_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
results.append(item)
|
||||
|
||||
return {
|
||||
"requested_concurrency": max_concurrent,
|
||||
"attempted_count": len(selected),
|
||||
"success_count": success_count,
|
||||
"skipped_count": skipped_count,
|
||||
"failed_count": failed_count,
|
||||
"triggered": len(selected),
|
||||
"users": [u.account for u in selected],
|
||||
"results": results,
|
||||
}
|
||||
|
||||
async def start(self):
|
||||
if self._running:
|
||||
@@ -52,6 +107,11 @@ class SchedulerService:
|
||||
self._run_interactions, IntervalTrigger(minutes=5),
|
||||
id="run_interactions", replace_existing=True
|
||||
)
|
||||
# 待发送回复队列:持久化延迟回复,后端重启后可继续发送
|
||||
self.scheduler.add_job(
|
||||
self._process_pending_reply_tasks, IntervalTrigger(seconds=30),
|
||||
id="process_pending_reply_tasks", replace_existing=True
|
||||
)
|
||||
# 每日零点重置计数
|
||||
self.scheduler.add_job(
|
||||
self._daily_reset, "cron", hour=16, minute=0, # 北京时间 00:00 = UTC 16:00
|
||||
@@ -221,7 +281,13 @@ class SchedulerService:
|
||||
user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user or user.status != 2:
|
||||
return
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"account": getattr(user, "account", ""),
|
||||
"status": "skipped",
|
||||
"reason": "user_not_logged_in",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# 检查今日评论限额
|
||||
can_comment = True
|
||||
@@ -255,7 +321,13 @@ class SchedulerService:
|
||||
f"用户 {user.account} 获取新闻列表为空 "
|
||||
f"(orgId={await news_service._cfg(db, 'platform_org_id', '')})"
|
||||
)
|
||||
return
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_articles",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# ── 文章去重 + 热度加权选取 ─────────────────────────────────
|
||||
# 查询今日已互动过的文章(所有类型),避免重复互动同一篇
|
||||
@@ -307,7 +379,14 @@ class SchedulerService:
|
||||
article_org_id = str(article.get("orgId") or "")
|
||||
|
||||
if not news_id:
|
||||
return
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "missing_news_id",
|
||||
"interactions": [],
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
# 读取互动概率
|
||||
comment_prob = float(await self._get_config_from_db(db, "comment_probability", "0.4"))
|
||||
@@ -317,6 +396,7 @@ class SchedulerService:
|
||||
forward_prob = float(await self._get_config_from_db(db, "forward_probability", "0.15"))
|
||||
|
||||
interactions_done = []
|
||||
action_failures = []
|
||||
|
||||
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
|
||||
await news_service.read_news(db, user, news_id)
|
||||
@@ -331,6 +411,8 @@ class SchedulerService:
|
||||
if success:
|
||||
interactions_done.append("like")
|
||||
await self._incr_total(db, user_id)
|
||||
else:
|
||||
action_failures.append({"type": "like", "error": err})
|
||||
|
||||
# ③ 收藏(每篇文章每用户每天只收藏一次)
|
||||
if "collect" not in done_on_this and random.random() < collect_prob:
|
||||
@@ -338,6 +420,8 @@ class SchedulerService:
|
||||
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
|
||||
if success:
|
||||
interactions_done.append("collect")
|
||||
else:
|
||||
action_failures.append({"type": "collect", "error": err})
|
||||
|
||||
# ④ 转发(每篇文章每用户每天只转发一次)
|
||||
if "forward" not in done_on_this and random.random() < forward_prob:
|
||||
@@ -346,109 +430,433 @@ class SchedulerService:
|
||||
if success:
|
||||
interactions_done.append("forward")
|
||||
await self._incr_total(db, user_id)
|
||||
else:
|
||||
action_failures.append({"type": "forward", "error": err})
|
||||
|
||||
# ⑤ 评论/回复逻辑(去重:已评论过的文章改为回复他人评论)
|
||||
# ⑤ 评论/回复逻辑:评论和回复互相独立,未评论过文章也可以回复他人评论
|
||||
if can_comment and personality:
|
||||
style_prompt = personality.comment_style_prompt or ""
|
||||
safe_word_max = min(personality.word_count_max, 80)
|
||||
|
||||
if already_commented_this:
|
||||
# 已评论过此文章 → 改为回复其他用户的评论(虚拟用户互动)
|
||||
if random.random() < reply_prob:
|
||||
existing = await news_service.get_comments(db, user, news_id)
|
||||
if existing:
|
||||
# 优先回复虚拟用户的评论(促进互动),过滤掉自己的评论
|
||||
from app.core.redis_client import get_session as _gs
|
||||
my_sess = await _gs(user.id)
|
||||
my_uid = my_sess.get("platform_uid", "") if my_sess else ""
|
||||
others = [c for c in existing
|
||||
if str(c.get("userId") or c.get("createUser") or "") != my_uid]
|
||||
if others:
|
||||
target = random.choice(others)
|
||||
cid = str(target.get("id") or target.get("commentId") or "")
|
||||
parent_content = target.get("content") or ""
|
||||
if cid:
|
||||
reply_text, r_tokens = await ai_service.generate_reply(
|
||||
db, news_title, parent_content,
|
||||
style_prompt,
|
||||
personality.word_count_min,
|
||||
safe_word_max
|
||||
)
|
||||
if reply_text:
|
||||
r_ok, r_err = await news_service.post_reply(
|
||||
db, user, news_id, cid, reply_text
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "reply",
|
||||
reply_text, r_tokens, r_ok, r_err,
|
||||
parent_comment_id=cid
|
||||
)
|
||||
if r_ok:
|
||||
interactions_done.append("reply")
|
||||
logger.info(f"💬 {user.account} 回复了已评论文章的评论(去重逻辑)")
|
||||
else:
|
||||
# 未评论过此文章 → 正常发评论
|
||||
if random.random() < comment_prob:
|
||||
comment_text, tokens = await ai_service.generate_comment(
|
||||
db, news_title, news_content,
|
||||
style_prompt, personality.word_count_min, safe_word_max
|
||||
)
|
||||
if comment_text:
|
||||
success, err = await news_service.post_comment(
|
||||
db, user, news_id, news_title, comment_text,
|
||||
news_author_id=news_author, org_id=article_org_id
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "comment",
|
||||
comment_text, tokens, success, err
|
||||
)
|
||||
if success:
|
||||
interactions_done.append("comment")
|
||||
await db.execute(
|
||||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||
today_comment_count=VirtualUser.today_comment_count + 1,
|
||||
total_interactions=VirtualUser.total_interactions + 1,
|
||||
last_interact_at=datetime.now()
|
||||
)
|
||||
)
|
||||
if random.random() < reply_prob:
|
||||
reply_actions, reply_failures = await self._run_reply_interaction_chain(
|
||||
db=db,
|
||||
starter=user,
|
||||
starter_personality=personality,
|
||||
news_service=news_service,
|
||||
ai_service=ai_service,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
style_prompt=style_prompt,
|
||||
safe_word_max=safe_word_max,
|
||||
)
|
||||
interactions_done.extend(reply_actions)
|
||||
action_failures.extend(reply_failures)
|
||||
|
||||
# ⑥ 评论成功后,随机回复其他用户的评论(互动链)
|
||||
if random.random() < reply_prob:
|
||||
existing = await news_service.get_comments(db, user, news_id)
|
||||
if existing:
|
||||
from app.core.redis_client import get_session as _gs2
|
||||
my_sess2 = await _gs2(user.id)
|
||||
my_uid2 = my_sess2.get("platform_uid", "") if my_sess2 else ""
|
||||
others2 = [c for c in existing
|
||||
if str(c.get("userId") or c.get("createUser") or "") != my_uid2]
|
||||
if others2:
|
||||
target2 = random.choice(others2)
|
||||
cid2 = str(target2.get("id") or target2.get("commentId") or "")
|
||||
parent_content2 = target2.get("content") or ""
|
||||
if cid2:
|
||||
reply_text2, r_tokens2 = await ai_service.generate_reply(
|
||||
db, news_title, parent_content2,
|
||||
style_prompt,
|
||||
personality.word_count_min,
|
||||
safe_word_max
|
||||
)
|
||||
if reply_text2:
|
||||
r_ok2, r_err2 = await news_service.post_reply(
|
||||
db, user, news_id, cid2, reply_text2
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "reply",
|
||||
reply_text2, r_tokens2, r_ok2, r_err2,
|
||||
parent_comment_id=cid2
|
||||
)
|
||||
if r_ok2:
|
||||
interactions_done.append("reply")
|
||||
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
|
||||
if not already_commented_this and random.random() < comment_prob:
|
||||
comment_text, tokens = await ai_service.generate_comment(
|
||||
db, news_title, news_content,
|
||||
style_prompt, personality.word_count_min, safe_word_max
|
||||
)
|
||||
if comment_text:
|
||||
success, err, comment_record_id = await news_service.post_comment_with_record_id(
|
||||
db, user, news_id, news_title, comment_text,
|
||||
news_author_id=news_author, org_id=article_org_id
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "comment",
|
||||
comment_text, tokens, success, err,
|
||||
platform_record_id=comment_record_id,
|
||||
)
|
||||
if success:
|
||||
interactions_done.append("comment")
|
||||
await db.execute(
|
||||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||
today_comment_count=VirtualUser.today_comment_count + 1,
|
||||
total_interactions=VirtualUser.total_interactions + 1,
|
||||
last_interact_at=datetime.now()
|
||||
)
|
||||
)
|
||||
else:
|
||||
action_failures.append({"type": "comment", "error": err})
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"👤 {user.account} 互动完成: {interactions_done} [新闻: {news_title[:20]}]")
|
||||
if interactions_done:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "success",
|
||||
"reason": "",
|
||||
"interactions": interactions_done,
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
if action_failures:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "failed",
|
||||
"reason": "; ".join(
|
||||
f"{item['type']}:{item['error'] or 'unknown'}" for item in action_failures
|
||||
),
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_actions_triggered",
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {user_id} 互动异常: {e}")
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"account": "",
|
||||
"status": "failed",
|
||||
"reason": str(e),
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
async def _run_reply_interaction_chain(
|
||||
self,
|
||||
db,
|
||||
starter: VirtualUser,
|
||||
starter_personality,
|
||||
news_service,
|
||||
ai_service,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
style_prompt: str,
|
||||
safe_word_max: int,
|
||||
) -> tuple[list[str], list[dict]]:
|
||||
"""主动回复评论,并按概率安排后续延迟回应。"""
|
||||
from app.core.redis_client import get_session
|
||||
|
||||
actions: list[str] = []
|
||||
failures: list[dict] = []
|
||||
starter_sess = await get_session(starter.id)
|
||||
starter_uid = str(starter_sess.get("platform_uid") or "") if starter_sess else ""
|
||||
comments = await news_service.get_comments(db, starter, news_id)
|
||||
if not comments:
|
||||
return actions, failures
|
||||
|
||||
candidates = [
|
||||
c for c in comments
|
||||
if str(c.get("id") or c.get("commentId") or "")
|
||||
and (c.get("content") or "").strip()
|
||||
and str(c.get("userId") or c.get("createUser") or "") != starter_uid
|
||||
]
|
||||
if not candidates:
|
||||
return actions, failures
|
||||
|
||||
parent_comment = random.choice(candidates)
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
root_content = (parent_comment.get("content") or "").strip()
|
||||
context = f"准备回复这条评论:{root_content}"
|
||||
reply_text, reply_tokens = await ai_service.generate_thread_reply(
|
||||
db, news_title, root_content, context,
|
||||
style_prompt,
|
||||
starter_personality.word_count_min,
|
||||
safe_word_max,
|
||||
)
|
||||
if not reply_text:
|
||||
return actions, failures
|
||||
|
||||
ok, err, reply_id = await news_service.post_reply_with_record_id(
|
||||
db, starter, news_id, parent_comment_id, reply_text,
|
||||
parent_comment=parent_comment,
|
||||
article_title=news_title,
|
||||
org_id=article_org_id,
|
||||
)
|
||||
await self._save_record(
|
||||
db, starter, news_id, news_title, "reply",
|
||||
reply_text, reply_tokens, ok, err,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=reply_id,
|
||||
)
|
||||
if not ok:
|
||||
failures.append({"type": "reply", "error": err})
|
||||
return actions, failures
|
||||
|
||||
actions.append("reply")
|
||||
await self._incr_total(db, starter.id)
|
||||
logger.info(f"💬 {starter.account} 回复了文章评论")
|
||||
|
||||
starter_reply = {
|
||||
"id": reply_id,
|
||||
"content": reply_text,
|
||||
"createUser": starter_uid,
|
||||
"fromUserName": starter.real_name or starter.nickname or starter.account,
|
||||
}
|
||||
|
||||
chain_probability = await self._get_float_config(db, "reply_chain_probability", 0.3)
|
||||
delay_min = await self._get_int_config(db, "reply_chain_delay_min_seconds", 30)
|
||||
delay_max = await self._get_int_config(db, "reply_chain_delay_max_seconds", 7200)
|
||||
if delay_max < delay_min:
|
||||
delay_max = delay_min
|
||||
|
||||
# 评论作者如果也是当前系统里的已登录虚拟用户,按概率安排稍后回复这条回复。
|
||||
parent_author_uid = str(parent_comment.get("createUser") or parent_comment.get("userId") or "")
|
||||
parent_author = await self._get_logged_in_user_by_platform_uid(db, parent_author_uid)
|
||||
if (
|
||||
parent_author
|
||||
and parent_author.id != starter.id
|
||||
and random.random() < chain_probability
|
||||
):
|
||||
delay_seconds = random.randint(delay_min, delay_max)
|
||||
await self._enqueue_pending_reply_task(
|
||||
db=db,
|
||||
delay_seconds=delay_seconds,
|
||||
actor_id=parent_author.id,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=starter_reply,
|
||||
root_content=root_content,
|
||||
context=f"对方刚回复了你的评论:{reply_text}",
|
||||
next_actor_id=starter.id,
|
||||
next_probability=chain_probability,
|
||||
next_delay_min=delay_min,
|
||||
next_delay_max=delay_max,
|
||||
)
|
||||
logger.info(
|
||||
f"⏳ {parent_author.account} 已进入待发送回复队列,延迟 {delay_seconds}s 后发送"
|
||||
)
|
||||
|
||||
return actions, failures
|
||||
|
||||
async def _enqueue_pending_reply_task(
|
||||
self,
|
||||
db,
|
||||
delay_seconds: int,
|
||||
actor_id: int,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
parent_comment: dict,
|
||||
reply_to: dict,
|
||||
root_content: str,
|
||||
context: str,
|
||||
next_actor_id: int | None = None,
|
||||
next_probability: float = 0.3,
|
||||
next_delay_min: int = 30,
|
||||
next_delay_max: int = 7200,
|
||||
):
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
db.add(PendingReplyTask(
|
||||
actor_user_id=actor_id,
|
||||
next_actor_user_id=next_actor_id,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
parent_comment_id=parent_comment_id,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
root_content=root_content,
|
||||
context=context,
|
||||
next_probability=next_probability,
|
||||
next_delay_min_seconds=next_delay_min,
|
||||
next_delay_max_seconds=next_delay_max,
|
||||
status=0,
|
||||
attempts=0,
|
||||
scheduled_at=datetime.now() + timedelta(seconds=max(0, delay_seconds)),
|
||||
))
|
||||
|
||||
async def _process_pending_reply_tasks(self):
|
||||
from app.services.news_service import news_service
|
||||
from app.services.ai_service import ai_service
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
update(PendingReplyTask)
|
||||
.where(
|
||||
PendingReplyTask.status == 1,
|
||||
PendingReplyTask.locked_at < now - timedelta(minutes=10),
|
||||
)
|
||||
.values(status=0, last_error="发送中超时,重新入队")
|
||||
)
|
||||
result = await db.execute(
|
||||
select(PendingReplyTask)
|
||||
.where(
|
||||
PendingReplyTask.status == 0,
|
||||
PendingReplyTask.scheduled_at <= now,
|
||||
)
|
||||
.order_by(PendingReplyTask.scheduled_at.asc())
|
||||
.limit(10)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
for task in tasks:
|
||||
await self._process_pending_reply_task(db, task, news_service, ai_service)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"待发送回复队列处理异常: {e}")
|
||||
|
||||
async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service):
|
||||
task.status = 1
|
||||
task.locked_at = datetime.now()
|
||||
task.attempts = (task.attempts or 0) + 1
|
||||
await db.flush()
|
||||
|
||||
actor = await self._get_user_by_id(db, task.actor_user_id)
|
||||
if not actor or actor.status != 2 or actor.is_enabled != 1:
|
||||
task.status = 3
|
||||
task.last_error = "用户未登录或已禁用"
|
||||
return
|
||||
|
||||
reply_result = await self._post_contextual_reply(
|
||||
db=db,
|
||||
actor=actor,
|
||||
news_service=news_service,
|
||||
ai_service=ai_service,
|
||||
news_id=task.news_id,
|
||||
news_title=task.news_title or "",
|
||||
article_org_id=task.article_org_id or "",
|
||||
parent_comment=task.parent_comment or {},
|
||||
reply_to=task.reply_to or {},
|
||||
root_content=task.root_content or "",
|
||||
context=task.context or "",
|
||||
)
|
||||
if not reply_result["ok"]:
|
||||
task.status = 3 if task.attempts >= 3 else 0
|
||||
task.last_error = reply_result["error"] or "生成或发送回复失败"
|
||||
if task.status == 0:
|
||||
task.scheduled_at = datetime.now() + timedelta(minutes=5)
|
||||
logger.warning(f"待发送回复失败 task_id={task.id} user={actor.account}: {task.last_error}")
|
||||
return
|
||||
|
||||
task.status = 2
|
||||
task.sent_at = datetime.now()
|
||||
task.last_error = None
|
||||
await self._incr_total(db, actor.id)
|
||||
logger.info(f"💬 {actor.account} 发送了待发送回复 task_id={task.id}")
|
||||
|
||||
if task.next_actor_user_id and random.random() < float(task.next_probability or 0.3):
|
||||
delay_min = int(task.next_delay_min_seconds or 30)
|
||||
delay_max = max(delay_min, int(task.next_delay_max_seconds or 7200))
|
||||
next_delay = random.randint(delay_min, delay_max)
|
||||
await self._enqueue_pending_reply_task(
|
||||
db=db,
|
||||
delay_seconds=next_delay,
|
||||
actor_id=task.next_actor_user_id,
|
||||
news_id=task.news_id,
|
||||
news_title=task.news_title or "",
|
||||
article_org_id=task.article_org_id or "",
|
||||
parent_comment=task.parent_comment or {},
|
||||
reply_to=reply_result["reply"],
|
||||
root_content=task.root_content or "",
|
||||
context=(
|
||||
f"原评论:{task.root_content or ''}\n"
|
||||
f"上一条回复:{(task.reply_to or {}).get('content') or ''}\n"
|
||||
f"对方回应:{reply_result['content']}"
|
||||
),
|
||||
next_actor_id=None,
|
||||
next_probability=float(task.next_probability or 0.3),
|
||||
next_delay_min=delay_min,
|
||||
next_delay_max=delay_max,
|
||||
)
|
||||
logger.info(f"⏳ 已入队继续回复,延迟 {next_delay}s 后发送")
|
||||
|
||||
async def _post_contextual_reply(
|
||||
self,
|
||||
db,
|
||||
actor: VirtualUser,
|
||||
news_service,
|
||||
ai_service,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
parent_comment: dict,
|
||||
reply_to: dict,
|
||||
root_content: str,
|
||||
context: str,
|
||||
) -> dict:
|
||||
personality = await self._get_user_personality(db, actor.id)
|
||||
style_prompt = personality.comment_style_prompt if personality else ""
|
||||
word_min = personality.word_count_min if personality else 15
|
||||
word_max = min(personality.word_count_max, 80) if personality else 60
|
||||
content, tokens = await ai_service.generate_thread_reply(
|
||||
db, news_title, root_content, context,
|
||||
style_prompt, word_min, word_max,
|
||||
)
|
||||
if not content:
|
||||
return {"ok": False, "error": "", "reply": {}, "content": ""}
|
||||
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
ok, err, reply_id = await news_service.post_reply_with_record_id(
|
||||
db, actor, news_id, parent_comment_id, content,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
article_title=news_title,
|
||||
org_id=article_org_id,
|
||||
)
|
||||
await self._save_record(
|
||||
db, actor, news_id, news_title, "reply",
|
||||
content, tokens, ok, err,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=reply_id,
|
||||
)
|
||||
from app.core.redis_client import get_session
|
||||
sess = await get_session(actor.id)
|
||||
actor_uid = str(sess.get("platform_uid") or "") if sess else ""
|
||||
return {
|
||||
"ok": ok,
|
||||
"error": "" if ok else err,
|
||||
"content": content,
|
||||
"reply": {
|
||||
"id": reply_id,
|
||||
"content": content,
|
||||
"createUser": actor_uid,
|
||||
"fromUserName": actor.real_name or actor.nickname or actor.account,
|
||||
},
|
||||
}
|
||||
|
||||
async def _get_logged_in_user_by_platform_uid(self, db, platform_uid: str) -> VirtualUser | None:
|
||||
if not platform_uid:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.platform_uid == platform_uid,
|
||||
VirtualUser.status == 2,
|
||||
VirtualUser.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_by_id(self, db, user_id: int) -> VirtualUser | None:
|
||||
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_personality(self, db, user_id: int):
|
||||
result = await db.execute(
|
||||
select(UserPersonality).where(UserPersonality.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_float_config(self, db, key: str, default: float) -> float:
|
||||
try:
|
||||
return float(await self._get_config_from_db(db, key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def _get_int_config(self, db, key: str, default: int) -> int:
|
||||
try:
|
||||
return int(float(await self._get_config_from_db(db, key, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def _incr_total(self, db, user_id: int):
|
||||
await db.execute(
|
||||
@@ -511,4 +919,4 @@ class SchedulerService:
|
||||
logger.info("每日计数重置完成")
|
||||
|
||||
|
||||
scheduler_service = SchedulerService()
|
||||
scheduler_service = SchedulerService()
|
||||
|
||||
@@ -230,7 +230,7 @@ class StatsService:
|
||||
"comment": "评论", "reply": "回复", "like": "点赞",
|
||||
"collect": "收藏", "forward": "转发"
|
||||
}
|
||||
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败"}
|
||||
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败", 3: "已取消"}
|
||||
|
||||
items = []
|
||||
for r in records:
|
||||
|
||||
6
digital-avatar-app/.dockerignore
Normal file
6
digital-avatar-app/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
.env
|
||||
*.log
|
||||
backend/
|
||||
4
digital-avatar-app/.gitignore
vendored
Normal file
4
digital-avatar-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
22
digital-avatar-app/Dockerfile
Normal file
22
digital-avatar-app/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# 构建阶段:安装依赖并打包 H5
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
# 跳过 vue-tsc 类型检查直接打包(与本机已知 vue-tsc + Node 版本兼容问题无关,保证可构建)
|
||||
RUN npx vite build
|
||||
|
||||
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
|
||||
# 锁定 1.28-alpine:测试服务器 Docker 的 seccomp 拦截 pwrite 系统调用,
|
||||
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 80
|
||||
6
digital-avatar-app/backend/.dockerignore
Normal file
6
digital-avatar-app/backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
15
digital-avatar-app/backend/Dockerfile
Normal file
15
digital-avatar-app/backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 后端依赖(fastapi/uvicorn/sqlalchemy/pypdf/python-docx/openpyxl 等)均为纯 Python wheel,
|
||||
# 无需 gcc 等编译链,故跳过 apt 安装以加快构建并减小镜像体积。
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
49
digital-avatar-app/backend/database.py
Normal file
49
digital-avatar-app/backend/database.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_FILE = os.path.join(BASE_DIR, "avatar.db")
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{DB_FILE}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
import models
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 轻量迁移:为已存在的表补充新列(SQLite 不支持自动 ALTER,逐列尝试)
|
||||
_try_add_columns(
|
||||
("qa_pairs", "enabled", "BOOLEAN DEFAULT 1"),
|
||||
("knowledge_docs", "vectorized", "BOOLEAN DEFAULT 0"),
|
||||
("knowledge_docs", "embedding_model", "VARCHAR DEFAULT ''"),
|
||||
("knowledge_docs", "chunk_count", "INTEGER DEFAULT 0"),
|
||||
("knowledge_docs", "vectorized_at", "TIMESTAMP"),
|
||||
("avatars", "owner_id", "VARCHAR DEFAULT ''"),
|
||||
)
|
||||
|
||||
|
||||
def _try_add_columns(*cols):
|
||||
with engine.connect() as conn:
|
||||
for table, col, ddl in cols:
|
||||
try:
|
||||
conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# 列已存在(或全新库由 create_all 建好)则忽略
|
||||
pass
|
||||
136
digital-avatar-app/backend/embeddings.py
Normal file
136
digital-avatar-app/backend/embeddings.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
向量化服务:对文档/查询文本生成向量。
|
||||
|
||||
优先级:
|
||||
1. 若配置了环境变量 EMBEDDING_API_URL,则调用第三方「OpenAI 兼容」的 /embeddings 接口
|
||||
(需配置 EMBEDDING_API_KEY、EMBEDDING_MODEL,默认 text-embedding-3-small)。
|
||||
2. 否则使用本地「哈希 TF 嵌入」兜底,使向量检索在无外部依赖时也能端到端跑通,
|
||||
且相似文本(共享词汇)会得到更高余弦相似度,便于演示召回效果。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.request
|
||||
|
||||
EMBED_DIM = 256
|
||||
MODEL = os.getenv("EMBEDDING_MODEL", "mock-hash-embed-v1")
|
||||
|
||||
|
||||
def _tokenize(text):
|
||||
text = (text or "").lower()
|
||||
# 英文/数字按词,CJK 逐字(中文无空格,需拆到字级才能命中子词)
|
||||
tokens = re.findall(r"[a-z0-9]+", text)
|
||||
tokens += re.findall(r"[一-鿿]", text)
|
||||
return tokens
|
||||
|
||||
|
||||
def _hash_embedding(texts, dim=EMBED_DIM):
|
||||
vecs = []
|
||||
for text in texts:
|
||||
vec = [0.0] * dim
|
||||
tokens = _tokenize(text)
|
||||
if not tokens:
|
||||
tokens = list(text or "")
|
||||
for tok in tokens:
|
||||
h = int(hashlib.md5(tok.encode("utf-8")).hexdigest(), 16)
|
||||
vec[h % dim] += 1.0
|
||||
norm = math.sqrt(sum(v * v for v in vec))
|
||||
if norm > 0:
|
||||
vec = [v / norm for v in vec]
|
||||
vecs.append(vec)
|
||||
return vecs
|
||||
|
||||
|
||||
def embed(texts):
|
||||
"""返回 list[list[float]],与输入顺序一致。"""
|
||||
if not texts:
|
||||
return []
|
||||
api_url = os.getenv("EMBEDDING_API_URL")
|
||||
if api_url:
|
||||
api_key = os.getenv("EMBEDDING_API_KEY", "")
|
||||
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
payload = json.dumps({"input": texts, "model": model}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}" if api_key else "",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
items = data["data"]
|
||||
if items and "index" in items[0]:
|
||||
items = sorted(items, key=lambda x: x["index"])
|
||||
return [item["embedding"] for item in items]
|
||||
return _hash_embedding(texts)
|
||||
|
||||
|
||||
def cosine(a, b):
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
if na == 0 or nb == 0:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def chunk_text(text, size=400, overlap=50):
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= size:
|
||||
return [text]
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(start + size, len(text))
|
||||
chunks.append(text[start:end])
|
||||
if end == len(text):
|
||||
break
|
||||
start = end - overlap
|
||||
return chunks
|
||||
|
||||
|
||||
def extract_text(path, ext):
|
||||
"""抽取文档纯文本;未知格式拒绝,已知格式解析失败时保留占位文本。"""
|
||||
if ext not in {".txt", ".md", ".docx", ".xlsx", ".pdf", ".doc"}:
|
||||
raise ValueError(f"unsupported file extension: {ext}")
|
||||
try:
|
||||
if ext in {".txt", ".md"}:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
if ext == ".docx":
|
||||
from docx import Document
|
||||
|
||||
doc = Document(path)
|
||||
return "\n".join(p.text for p in doc.paragraphs)
|
||||
if ext == ".xlsx":
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
|
||||
rows = []
|
||||
for ws in wb.worksheets:
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
cells = [str(c) for c in row if c is not None]
|
||||
if cells:
|
||||
rows.append(" ".join(cells))
|
||||
return "\n".join(rows)
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
from PyPDF2 import PdfReader
|
||||
reader = PdfReader(path)
|
||||
return "\n".join((p.extract_text() or "") for p in reader.pages)
|
||||
if ext == ".doc":
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read().decode("utf-8", errors="ignore")
|
||||
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]+", " ", raw)
|
||||
except Exception as e: # 解析失败时回退
|
||||
print("extract_text failed:", e)
|
||||
return f"文档:{os.path.basename(path)} 类型 {ext}"
|
||||
105
digital-avatar-app/backend/main.py
Normal file
105
digital-avatar-app/backend/main.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import os
|
||||
|
||||
from database import init_db, SessionLocal
|
||||
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import routers.avatars
|
||||
import routers.tokens
|
||||
import routers.authorizations
|
||||
import routers.organizations
|
||||
import routers.knowledge
|
||||
import routers.huihui_auth
|
||||
import routers.chat
|
||||
from responses import ok
|
||||
|
||||
app = FastAPI(title="会会数字分身 API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(routers.avatars.router, prefix="/api")
|
||||
app.include_router(routers.tokens.router, prefix="/api")
|
||||
app.include_router(routers.authorizations.router, prefix="/api")
|
||||
app.include_router(routers.organizations.router, prefix="/api")
|
||||
app.include_router(routers.knowledge.router, prefix="/api")
|
||||
app.include_router(routers.huihui_auth.router, prefix="/api")
|
||||
app.include_router(routers.chat.router, prefix="/api")
|
||||
|
||||
UPLOAD_DIR = routers.knowledge.UPLOAD_DIR
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
app.mount("/api/files", StaticFiles(directory=UPLOAD_DIR), name="knowledge-files")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return ok({"status": "ok"})
|
||||
|
||||
|
||||
def seed():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if db.query(TokenAccount).first() is None:
|
||||
db.add(TokenAccount(balance=1250))
|
||||
|
||||
if db.query(TokenPlan).count() == 0:
|
||||
plans = [
|
||||
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"),
|
||||
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"),
|
||||
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"),
|
||||
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"),
|
||||
]
|
||||
db.add_all(plans)
|
||||
|
||||
if db.query(Avatar).count() == 0:
|
||||
avatar = Avatar(
|
||||
name="我的数字分身",
|
||||
display_name="会会助手",
|
||||
description="我是您的AI数字分身,可以帮您管理日程、回复消息、处理任务。",
|
||||
emoji="🤖",
|
||||
status="active",
|
||||
token_balance=0,
|
||||
config={
|
||||
"replyStyle": "professional",
|
||||
"creativity": 50,
|
||||
"rigor": 50,
|
||||
"humor": 30,
|
||||
"responseLength": "medium",
|
||||
"systemPrompt": "",
|
||||
},
|
||||
)
|
||||
db.add(avatar)
|
||||
db.commit()
|
||||
db.refresh(avatar)
|
||||
|
||||
if db.query(Authorization).count() == 0:
|
||||
auths = [
|
||||
Authorization(avatar_id=avatar.id, target_type="application", target_name="微信小程序", permissions=["read", "reply"], status="active"),
|
||||
Authorization(avatar_id=avatar.id, target_type="user", target_name="张三", permissions=["read"], status="active"),
|
||||
Authorization(avatar_id=avatar.id, target_type="organization", target_name="产品团队", permissions=["read", "edit"], status="inactive"),
|
||||
]
|
||||
db.add_all(auths)
|
||||
|
||||
if db.query(Organization).count() == 0:
|
||||
orgs = [
|
||||
Organization(name="会会增长团队", description="负责会会产品的增长与运营", emoji="🚀", org_type="team", member_count=12),
|
||||
Organization(name="AI 实验室", description="探索前沿 AI 能力", emoji="💡", org_type="company", member_count=8),
|
||||
]
|
||||
db.add_all(orgs)
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
seed()
|
||||
220
digital-avatar-app/backend/models.py
Normal file
220
digital-avatar-app/backend/models.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, JSON, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
return dt.isoformat() if dt else None
|
||||
|
||||
|
||||
class Avatar(Base):
|
||||
__tablename__ = "avatars"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
owner_id = Column(String, default="", index=True) # 归属用户(会会 huihui_user_id);空=未归属/种子数据
|
||||
name = Column(String, nullable=False)
|
||||
display_name = Column(String, default="")
|
||||
description = Column(Text, default="")
|
||||
photo_url = Column(String, default="")
|
||||
emoji = Column(String, default="🤖")
|
||||
status = Column(String, default="active") # active | inactive | training
|
||||
token_balance = Column(Integer, default=0)
|
||||
config = Column(JSON, default=dict)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"ownerId": self.owner_id,
|
||||
"name": self.name,
|
||||
"displayName": self.display_name,
|
||||
"description": self.description,
|
||||
"photoUrl": self.photo_url,
|
||||
"emoji": self.emoji,
|
||||
"status": self.status,
|
||||
"tokenBalance": self.token_balance,
|
||||
"config": self.config or {},
|
||||
"createdAt": _iso(self.created_at),
|
||||
"updatedAt": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class Authorization(Base):
|
||||
__tablename__ = "authorizations"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
target_type = Column(String, default="user") # user | organization | application
|
||||
target_id = Column(String, default="")
|
||||
target_name = Column(String, default="")
|
||||
permissions = Column(JSON, default=list)
|
||||
status = Column(String, default="active") # active | inactive
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"targetType": self.target_type,
|
||||
"targetId": self.target_id,
|
||||
"targetName": self.target_name,
|
||||
"permissions": self.permissions or [],
|
||||
"status": self.status,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, default="")
|
||||
emoji = Column(String, default="🏢")
|
||||
org_type = Column(String, default="team") # team | company | community
|
||||
role = Column(String, default="admin") # admin | member | viewer
|
||||
member_count = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"emoji": self.emoji,
|
||||
"type": self.org_type,
|
||||
"role": self.role,
|
||||
"memberCount": self.member_count,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeDoc(Base):
|
||||
__tablename__ = "knowledge_docs"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
filename = Column(String, default="")
|
||||
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
||||
file_size = Column(Integer, default=0)
|
||||
file_url = Column(String, default="")
|
||||
status = Column(String, default="uploaded") # uploaded | parsing | ready
|
||||
vectorized = Column(Boolean, default=False) # 是否已向量化
|
||||
embedding_model = Column(String, default="") # 向量模型标识
|
||||
chunk_count = Column(Integer, default=0) # 切片数量
|
||||
vectorized_at = Column(DateTime) # 向量化时间
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"filename": self.filename,
|
||||
"fileType": self.file_type,
|
||||
"fileSize": self.file_size,
|
||||
"fileUrl": self.file_url,
|
||||
"status": self.status,
|
||||
"vectorized": bool(self.vectorized),
|
||||
"embeddingModel": self.embedding_model,
|
||||
"chunkCount": self.chunk_count,
|
||||
"vectorizedAt": _iso(self.vectorized_at),
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class QAPair(Base):
|
||||
__tablename__ = "qa_pairs"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
question = Column(Text, default="")
|
||||
answer = Column(Text, default="")
|
||||
enabled = Column(Boolean, default=True) # 是否启用(关闭后不参与作答)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"question": self.question,
|
||||
"answer": self.answer,
|
||||
"enabled": bool(self.enabled),
|
||||
"createdAt": _iso(self.created_at),
|
||||
"updatedAt": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeChunk(Base):
|
||||
__tablename__ = "knowledge_chunks"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
doc_id = Column(String, default="") # 关联 KnowledgeDoc.id
|
||||
avatar_id = Column(String, default="")
|
||||
content = Column(Text, default="") # 切片文本
|
||||
vector = Column(Text, default="") # JSON 编码的向量
|
||||
chunk_index = Column(Integer, default=0)
|
||||
embedding_model = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"docId": self.doc_id,
|
||||
"avatarId": self.avatar_id,
|
||||
"content": self.content,
|
||||
"chunkIndex": self.chunk_index,
|
||||
"embeddingModel": self.embedding_model,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class TokenAccount(Base):
|
||||
__tablename__ = "token_account"
|
||||
id = Column(Integer, primary_key=True)
|
||||
balance = Column(Integer, default=1250)
|
||||
|
||||
|
||||
class TokenPlan(Base):
|
||||
__tablename__ = "token_plans"
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, default="")
|
||||
amount = Column(Integer, default=0)
|
||||
price = Column(Float, default=0)
|
||||
badge = Column(String, default="")
|
||||
desc = Column(String, default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"amount": self.amount,
|
||||
"price": self.price,
|
||||
"badge": self.badge,
|
||||
"desc": self.desc,
|
||||
}
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
|
||||
|
||||
__tablename__ = "users"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
huihui_user_id = Column(String, default="", index=True) # 会会 userId(唯一标识)
|
||||
phone = Column(String, default="", index=True)
|
||||
nickname = Column(String, default="")
|
||||
avatar_url = Column(String, default="")
|
||||
huihui_token = Column(String, default="") # 会会 access_token
|
||||
app_token = Column(String, default="") # 本系统会话 token
|
||||
last_login_at = Column(DateTime)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"huihuiUserId": self.huihui_user_id,
|
||||
"phone": self.phone,
|
||||
"nickname": self.nickname,
|
||||
"avatarUrl": self.avatar_url,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"lastLoginAt": _iso(self.last_login_at),
|
||||
}
|
||||
9
digital-avatar-app/backend/requirements.txt
Normal file
9
digital-avatar-app/backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
python-multipart
|
||||
httpx
|
||||
pypdf
|
||||
python-docx
|
||||
openpyxl
|
||||
6
digital-avatar-app/backend/responses.py
Normal file
6
digital-avatar-app/backend/responses.py
Normal file
@@ -0,0 +1,6 @@
|
||||
def ok(data=None, message="success"):
|
||||
return {"code": 200, "message": message, "data": data}
|
||||
|
||||
|
||||
def fail(message="error", code=400):
|
||||
return {"code": code, "message": message, "data": None}
|
||||
0
digital-avatar-app/backend/routers/__init__.py
Normal file
0
digital-avatar-app/backend/routers/__init__.py
Normal file
32
digital-avatar-app/backend/routers/authorizations.py
Normal file
32
digital-avatar-app/backend/routers/authorizations.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Authorization
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["授权"])
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}/authorizations")
|
||||
def list_auth(avatar_id: str, db: Session = Depends(get_db)):
|
||||
# demo:返回全部授权(忽略具体 avatar 绑定,便于联调)
|
||||
items = db.query(Authorization).order_by(Authorization.created_at.desc()).all()
|
||||
return ok([a.to_dict() for a in items])
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/authorizations")
|
||||
def update_auth(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
auth_id = payload.get("id")
|
||||
if not auth_id:
|
||||
return fail("缺少授权 id", 400)
|
||||
a = db.query(Authorization).filter(Authorization.id == auth_id).first()
|
||||
if not a:
|
||||
return fail("授权不存在", 404)
|
||||
if "status" in payload:
|
||||
a.status = payload["status"]
|
||||
if "permissions" in payload:
|
||||
a.permissions = payload["permissions"]
|
||||
db.commit()
|
||||
items = db.query(Authorization).order_by(Authorization.created_at.desc()).all()
|
||||
return ok([x.to_dict() for x in items])
|
||||
95
digital-avatar-app/backend/routers/avatars.py
Normal file
95
digital-avatar-app/backend/routers/avatars.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, Body, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["分身"])
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
"""从 Authorization: Bearer <app_token> 解析当前登录用户"""
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
@router.get("/avatar")
|
||||
def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
# 仅返回当前登录用户自己的分身;未登录返回空,避免看到种子/他人数据
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
return ok({"data": [], "total": 0})
|
||||
q = db.query(Avatar).filter(Avatar.owner_id == user.huihui_user_id)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(Avatar.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return ok({"data": [a.to_dict() for a in items], "total": total})
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}")
|
||||
def get_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.post("/avatar")
|
||||
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _resolve_user(authorization, db)
|
||||
a = Avatar(
|
||||
owner_id=user.huihui_user_id if user else "",
|
||||
name=payload.get("name", "未命名分身"),
|
||||
display_name=payload.get("displayName", "") or payload.get("display_name", ""),
|
||||
description=payload.get("description", ""),
|
||||
photo_url=payload.get("photoUrl", "") or payload.get("photo_url", ""),
|
||||
emoji=payload.get("emoji", "🤖"),
|
||||
status=payload.get("status", "active"),
|
||||
token_balance=payload.get("tokenBalance", 0),
|
||||
config=payload.get("config", {}) or {},
|
||||
)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}")
|
||||
def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
mapping = {
|
||||
"displayName": "display_name",
|
||||
"photoUrl": "photo_url",
|
||||
"tokenBalance": "token_balance",
|
||||
}
|
||||
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
|
||||
if key in payload:
|
||||
col = mapping.get(key, key)
|
||||
setattr(a, col, payload[key])
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}")
|
||||
def delete_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
# 级联清理关联数据,避免孤儿记录
|
||||
db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete()
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete()
|
||||
db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete()
|
||||
db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete()
|
||||
db.delete(a)
|
||||
db.commit()
|
||||
return ok({"success": True})
|
||||
205
digital-avatar-app/backend/routers/chat.py
Normal file
205
digital-avatar-app/backend/routers/chat.py
Normal file
@@ -0,0 +1,205 @@
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import embeddings
|
||||
from database import get_db
|
||||
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["数字分身聊天"])
|
||||
|
||||
CHAT_API_URL = os.getenv("CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
CHAT_API_KEY = os.getenv("CHAT_API_KEY", "")
|
||||
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen-plus")
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
MAX_HISTORY_MESSAGES = 10
|
||||
QA_SIMILARITY_THRESHOLD = 0.86
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str = Field(pattern="^(user|assistant)$")
|
||||
content: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
|
||||
|
||||
|
||||
class ChatIn(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
|
||||
history: list[ChatMessage] = Field(default_factory=list, max_length=MAX_HISTORY_MESSAGES)
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not avatar:
|
||||
raise HTTPException(status_code=404, detail="分身不存在")
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该分身")
|
||||
return avatar
|
||||
|
||||
|
||||
def _normalize_question(value: str) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
value = re.sub(r"\s+", "", value)
|
||||
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
|
||||
|
||||
|
||||
def _match_standard_qa(question: str, qa_pairs: list[Any]):
|
||||
normalized = _normalize_question(question)
|
||||
if not normalized:
|
||||
return None
|
||||
enabled = [qa for qa in qa_pairs if getattr(qa, "enabled", True)]
|
||||
for qa in enabled:
|
||||
if _normalize_question(getattr(qa, "question", "")) == normalized:
|
||||
return qa
|
||||
best = None
|
||||
best_score = 0.0
|
||||
for qa in enabled:
|
||||
candidate = _normalize_question(getattr(qa, "question", ""))
|
||||
if not candidate:
|
||||
continue
|
||||
score = difflib.SequenceMatcher(None, normalized, candidate).ratio()
|
||||
if score > best_score:
|
||||
best, best_score = qa, score
|
||||
return best if best_score >= QA_SIMILARITY_THRESHOLD else None
|
||||
|
||||
|
||||
def _config(avatar: Avatar) -> dict:
|
||||
config = getattr(avatar, "config", None) or {}
|
||||
return {
|
||||
"replyStyle": config.get("replyStyle", "professional"),
|
||||
"creativity": max(0, min(100, int(config.get("creativity", 50)))),
|
||||
"rigor": max(0, min(100, int(config.get("rigor", 50)))),
|
||||
"humor": max(0, min(100, int(config.get("humor", 30)))),
|
||||
"responseLength": config.get("responseLength", "medium"),
|
||||
"systemPrompt": (config.get("systemPrompt", "") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]:
|
||||
config = _config(avatar)
|
||||
knowledge = "\n".join(
|
||||
f"[{hit.get('filename', '知识库')}] {hit.get('snippet', '')}"
|
||||
for hit in knowledge_hits
|
||||
if hit.get("snippet")
|
||||
)
|
||||
system = (
|
||||
"你是用户的专属数字分身。请基于已提供的知识库回答,不要编造事实;"
|
||||
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
|
||||
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
|
||||
)
|
||||
if config["systemPrompt"]:
|
||||
system += f"\n额外系统提示词:{config['systemPrompt']}"
|
||||
if knowledge:
|
||||
system += f"\n以下是可参考的知识库内容:\n{knowledge}"
|
||||
messages = [{"role": "system", "content": system}]
|
||||
for item in history[-MAX_HISTORY_MESSAGES:]:
|
||||
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
|
||||
messages.append({"role": "user", "content": question.strip()})
|
||||
return messages
|
||||
|
||||
|
||||
def _search_knowledge(db: Session, avatar_id: str, question: str, top_k: int = 5) -> list[dict]:
|
||||
chunks = db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).all()
|
||||
if not chunks:
|
||||
return []
|
||||
qvec = embeddings.embed([question])[0]
|
||||
scored = []
|
||||
for chunk in chunks:
|
||||
try:
|
||||
vector = __import__("json").loads(chunk.vector)
|
||||
except Exception:
|
||||
continue
|
||||
scored.append((embeddings.cosine(qvec, vector), chunk))
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
results = []
|
||||
for score, chunk in scored[: max(1, top_k)]:
|
||||
doc = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == chunk.doc_id).first()
|
||||
results.append({
|
||||
"docId": chunk.doc_id,
|
||||
"filename": doc.filename if doc else "",
|
||||
"fileType": doc.file_type if doc else "",
|
||||
"snippet": chunk.content[:120] + ("…" if len(chunk.content) > 120 else ""),
|
||||
"score": round(score, 4),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _call_qwen(messages: list[dict], temperature: float) -> str:
|
||||
if not CHAT_API_KEY:
|
||||
raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
|
||||
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
|
||||
payload = {
|
||||
"model": CHAT_MODEL,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
try:
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {CHAT_API_KEY}"},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
answer = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
|
||||
raise RuntimeError("Qwen 模型服务暂时不可用") from exc
|
||||
if not isinstance(answer, str) or not answer.strip():
|
||||
raise RuntimeError("Qwen 模型没有返回有效回答")
|
||||
return answer.strip()
|
||||
|
||||
|
||||
def _resolve_reply(
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
question: str,
|
||||
history: list[Any],
|
||||
*,
|
||||
qa_pairs: list[Any] | None = None,
|
||||
search_fn: Callable[..., list[dict]] | None = None,
|
||||
model_client: Callable[..., str] | None = None,
|
||||
) -> dict:
|
||||
if qa_pairs is None:
|
||||
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
||||
matched = _match_standard_qa(question, qa_pairs)
|
||||
if matched:
|
||||
return {"answer": matched.answer, "source": "qa", "references": []}
|
||||
|
||||
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
|
||||
hits = search_fn(question, avatar.id)
|
||||
messages = _build_prompt(avatar, history, question, hits)
|
||||
config = _config(avatar)
|
||||
temperature = 0.2 + config["creativity"] / 100 * 0.6
|
||||
model_client = model_client or _call_qwen
|
||||
answer = model_client(messages=messages, temperature=temperature)
|
||||
return {
|
||||
"answer": answer,
|
||||
"source": "knowledge" if hits else "qwen",
|
||||
"references": hits,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/chat")
|
||||
def chat(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||
try:
|
||||
return ok(_resolve_reply(db, avatar, body.message, body.history))
|
||||
except RuntimeError as exc:
|
||||
return fail(str(exc), code=502)
|
||||
336
digital-avatar-app/backend/routers/huihui_auth.py
Normal file
336
digital-avatar-app/backend/routers/huihui_auth.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
会会短信验证码登录代理(真实开放平台对接)
|
||||
──────────────────────────────────────────────
|
||||
严格按会会开放平台 sign.js 签名范式:
|
||||
- 公共字段 appId/accessId/timestamp(12小时制hh)/signType/signVersion/accessSecret/nonce 合并业务参数
|
||||
- 过滤空值 → 字典序排序 → k=v& 拼接 → 末尾追加 accessSecret=secretKey → MD5/SHA256 大写
|
||||
- 认证服务基址 / appId / accessId / accessSecret / clientCode 走环境变量
|
||||
真实端点(来自 fat-open 网关 usercenter 的 Swagger):
|
||||
- 发送验证码:POST {BASE}{HUIHUI_SMS_SEND_PATH} 默认 /open/mobile/sms/code (query 参数)
|
||||
- 短信登录: POST {BASE}{HUIHUI_SMS_LOGIN_PATH} 默认 /open/login/token,loginType=code
|
||||
登录成功后建/链本地 users 表(按会会 userId 唯一),签发本系统 app_token 作为会话。
|
||||
无真实凭证时仍可走 DEV_MOCK 兜底联调。
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import random
|
||||
import string
|
||||
import hashlib
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# 会会网关按北京时间(Asia/Shanghai, UTC+8)校验时间戳,容器默认 UTC 会导致签名被拒。
|
||||
# 用固定 +8 偏移(不依赖 tzdata,slim 镜像缺 IANA 库时会抛 ZoneInfoNotFoundError)。
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["会会账号"])
|
||||
|
||||
# ── 会会开放平台配置(环境变量)──
|
||||
AUTH_BASE_URL = os.getenv("HUIHUI_AUTH_BASE_URL", "https://fat-open.99hui.com/api/usercenter")
|
||||
APP_ID = os.getenv("HUIHUI_APP_ID", "")
|
||||
ACCESS_ID = os.getenv("HUIHUI_ACCESS_ID", "")
|
||||
ACCESS_SECRET = os.getenv("HUIHUI_ACCESS_SECRET", "")
|
||||
CLIENT_CODE = os.getenv("HUIHUI_CLIENT_CODE", "")
|
||||
SMS_SEND_PATH = os.getenv("HUIHUI_SMS_SEND_PATH", "/open/mobile/sms/code")
|
||||
SMS_LOGIN_PATH = os.getenv("HUIHUI_SMS_LOGIN_PATH", "/open/login/token")
|
||||
|
||||
# 临时开发态:无真实会会凭证时,本地模拟短信收发,便于端到端联调。
|
||||
DEV_MOCK = os.getenv("HUIHUI_DEV_MOCK", "false").lower() in ("1", "true", "yes")
|
||||
_mock_codes: dict[str, tuple[str, float]] = {}
|
||||
_MOCK_TTL = 300
|
||||
|
||||
|
||||
# ── 签名体系(完全对应 sign.js)──
|
||||
def _get_nonce() -> str:
|
||||
# 与会会 sign.js 一致:base36 随机串
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
||||
|
||||
|
||||
def _get_timestamp() -> str:
|
||||
# 与会会 sign.js 一致:yyyyMMddHHmmss(24 小时制大写 HH)。
|
||||
# 实测:会会 common.format 走 24 小时制,用 12 小时制(%I)会被网关判"签名验证失败"。
|
||||
# 必须用北京时间,否则容器(UTC)生成的时间戳与会会网关校验窗口偏差 8h 被拒。
|
||||
return datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
|
||||
SIGN_KEY = "signature"
|
||||
SECRET_KEY = "accessSecret"
|
||||
keys = sorted(params.keys())
|
||||
parts = []
|
||||
for k in keys:
|
||||
if k in (SIGN_KEY, SECRET_KEY):
|
||||
continue
|
||||
v = params.get(k)
|
||||
if v is None or v == "" or v == []:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
continue
|
||||
parts.append(f"{k}={v}")
|
||||
sign_str = "&".join(parts) + f"&{SECRET_KEY}={secret_key}"
|
||||
if sign_type.upper() == "SHA256":
|
||||
return hashlib.sha256(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def _build_form(extra: dict) -> dict:
|
||||
sign_type = "MD5"
|
||||
sign_version = "1.0"
|
||||
secret = ACCESS_SECRET
|
||||
base = {
|
||||
"appId": APP_ID,
|
||||
"accessId": ACCESS_ID,
|
||||
"timestamp": _get_timestamp(),
|
||||
"signType": sign_type,
|
||||
"signVersion": sign_version,
|
||||
"accessSecret": secret,
|
||||
"nonce": _get_nonce(),
|
||||
}
|
||||
base.update(extra)
|
||||
signature = _make_sign(base, secret, sign_type) if secret else ""
|
||||
base["signature"] = signature
|
||||
base.pop("accessSecret", None) # 不发送密钥
|
||||
return base
|
||||
|
||||
|
||||
def _pick(d: dict, *keys, default=""):
|
||||
for k in keys:
|
||||
if d.get(k) not in (None, ""):
|
||||
return d[k]
|
||||
return default
|
||||
|
||||
|
||||
def _cfg_ready() -> bool:
|
||||
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
|
||||
|
||||
|
||||
def _call_huihui(path: str, params: dict, as_query: bool = False):
|
||||
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
|
||||
url = f"{AUTH_BASE_URL}{path}"
|
||||
with httpx.Client(timeout=30, follow_redirects=True) as c:
|
||||
if as_query:
|
||||
resp = c.post(url, params=params)
|
||||
else:
|
||||
resp = c.post(url, data=params)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return False, {"message": f"会会返回非JSON: {resp.text[:200]}"}, resp.status_code
|
||||
# 会会统一包装 {code, message, data}
|
||||
code = data.get("code")
|
||||
if resp.status_code == 200 and code in (0, 200, "0", "200"):
|
||||
return True, data, resp.status_code
|
||||
return False, data, resp.status_code
|
||||
|
||||
|
||||
@router.post("/huihui/sms/send")
|
||||
def sms_send(body: dict = Body(...)):
|
||||
"""请求会会发送短信验证码(真实开放平台 /open/mobile/sms/code)"""
|
||||
phone = (body.get("phone") or "").strip()
|
||||
if not phone or not phone.isdigit() or len(phone) != 11:
|
||||
return fail("请输入正确的 11 位手机号", 400)
|
||||
|
||||
# 临时开发态:本地模拟发码
|
||||
if DEV_MOCK and not _cfg_ready():
|
||||
code = str(random.randint(100000, 999999))
|
||||
_mock_codes[phone] = (code, datetime.now().timestamp() + _MOCK_TTL)
|
||||
return ok({"sent": True, "devCode": code, "dev": True})
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会短信服务未配置(缺少 HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET)", 500)
|
||||
|
||||
# /open/mobile/sms/code:mobile 与公共字段均走 query
|
||||
form = _build_form({"mobile": phone})
|
||||
ok_flag, data, status = _call_huihui(SMS_SEND_PATH, form, as_query=True)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"发送失败(HTTP {status})", 502)
|
||||
return ok({"sent": True})
|
||||
|
||||
|
||||
@router.post("/huihui/sms/login")
|
||||
def sms_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""短信验证码登录:调会会 /open/login/token(loginType=code) 换取 access_token + userId,落库并签发本系统会话"""
|
||||
phone = (body.get("phone") or "").strip()
|
||||
code = (body.get("code") or "").strip()
|
||||
if not phone or not code:
|
||||
return fail("手机号或验证码缺失", 400)
|
||||
|
||||
# 临时开发态:本地校验模拟码
|
||||
if DEV_MOCK and not _cfg_ready():
|
||||
rec = _mock_codes.get(phone)
|
||||
if not rec or rec[1] < datetime.now().timestamp():
|
||||
return fail("验证码已失效,请重新获取", 401)
|
||||
if rec[0] != code:
|
||||
return fail("验证码错误", 401)
|
||||
_mock_codes.pop(phone, None)
|
||||
return _issue_session(db, phone, {
|
||||
"userId": f"dev_{phone}",
|
||||
"nickname": f"会会用户{phone[-4:]}",
|
||||
"avatarUrl": "",
|
||||
"token": f"dev_token_{phone}",
|
||||
})
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
extra = {
|
||||
"username": phone,
|
||||
"password": code,
|
||||
"loginType": "code",
|
||||
"grantType": "password",
|
||||
"isRegister": "true",
|
||||
}
|
||||
if CLIENT_CODE:
|
||||
extra["clientCode"] = CLIENT_CODE
|
||||
form = _build_form(extra)
|
||||
ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"登录失败(HTTP {status})", 401)
|
||||
|
||||
raw = data.get("data") or {}
|
||||
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||
huihui_token = (
|
||||
_pick(raw, "accessToken", "access_token", "token")
|
||||
or _pick(user_info, "accessToken", "access_token", "token")
|
||||
)
|
||||
huihui_user_id = (
|
||||
_pick(raw, "openid", "userId", "uid", "openId", "id")
|
||||
or _pick(user_info, "openid", "userId", "uid", "openId", "id")
|
||||
)
|
||||
nickname = (
|
||||
_pick(raw, "nickName", "nickname", "name", "userName")
|
||||
or _pick(user_info, "nickName", "nickname", "name", "userName")
|
||||
)
|
||||
avatar_url = (
|
||||
_pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
)
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
return _issue_session(db, phone, {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/huihui/pwd/login")
|
||||
def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""账号密码登录:调会会 /open/login/token(loginType=password) 换取 access_token + userId,落库并签发本系统会话。
|
||||
与 news_service 既有对接完全一致:username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false。
|
||||
"""
|
||||
account = (body.get("account") or "").strip()
|
||||
password = (body.get("password") or "").strip()
|
||||
if not account or not password:
|
||||
return fail("账号或密码缺失", 400)
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
extra = {
|
||||
"username": account,
|
||||
"password": password,
|
||||
"loginType": "password",
|
||||
"grantType": "password",
|
||||
"isRegister": "false",
|
||||
}
|
||||
if CLIENT_CODE:
|
||||
extra["clientCode"] = CLIENT_CODE
|
||||
form = _build_form(extra)
|
||||
ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"登录失败(HTTP {status})", 401)
|
||||
|
||||
raw = data.get("data") or {}
|
||||
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||
huihui_token = (
|
||||
_pick(raw, "accessToken", "access_token", "token")
|
||||
or _pick(user_info, "accessToken", "access_token", "token")
|
||||
)
|
||||
huihui_user_id = (
|
||||
_pick(raw, "openid", "userId", "uid", "openId", "id")
|
||||
or _pick(user_info, "openid", "userId", "uid", "openId", "id")
|
||||
)
|
||||
nickname = (
|
||||
_pick(raw, "nickName", "nickname", "name", "userName")
|
||||
or _pick(user_info, "nickName", "nickname", "name", "userName")
|
||||
)
|
||||
avatar_url = (
|
||||
_pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
)
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
# 账号即手机号时记录,便于资料展示;非手机号(用户名)则不覆盖已有 phone
|
||||
phone = account if (account.isdigit() and len(account) == 11) else ""
|
||||
return _issue_session(db, phone, {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
})
|
||||
|
||||
|
||||
def _issue_session(db: Session, phone: str, info: dict):
|
||||
"""建/链本地用户并签发本系统会话 token"""
|
||||
huihui_user_id = info.get("userId", "")
|
||||
user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first()
|
||||
if not user:
|
||||
user = User(huihui_user_id=huihui_user_id)
|
||||
if phone:
|
||||
user.phone = phone
|
||||
if info.get("nickname"):
|
||||
user.nickname = info["nickname"]
|
||||
if info.get("avatarUrl"):
|
||||
user.avatar_url = info["avatarUrl"]
|
||||
user.huihui_token = info.get("token", "")
|
||||
user.app_token = uuid.uuid4().hex
|
||||
user.last_login_at = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return ok({
|
||||
"token": user.app_token,
|
||||
"user": user.to_dict(),
|
||||
"huihui": {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": info.get("nickname", ""),
|
||||
"avatarUrl": info.get("avatarUrl", ""),
|
||||
"token": info.get("token", ""),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.get("/huihui/me")
|
||||
def me(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""当前登录用户信息(Bearer app_token)"""
|
||||
if not authorization:
|
||||
return fail("未登录", 401)
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if not user:
|
||||
return fail("会话无效或已过期", 401)
|
||||
return ok(user.to_dict())
|
||||
|
||||
|
||||
@router.post("/huihui/logout")
|
||||
def logout(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""退出登录(作废 app_token)"""
|
||||
if authorization:
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if user:
|
||||
user.app_token = ""
|
||||
db.commit()
|
||||
return ok({"success": True})
|
||||
278
digital-avatar-app/backend/routers/knowledge.py
Normal file
278
digital-avatar-app/backend/routers/knowledge.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import KnowledgeDoc, QAPair, KnowledgeChunk, Avatar, User
|
||||
from responses import ok, fail
|
||||
import embeddings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ALLOWED_EXT = {".md", ".txt", ".pdf", ".doc", ".docx", ".xlsx"}
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
class QAIn(BaseModel):
|
||||
question: str = ""
|
||||
answer: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class EnabledIn(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not avatar:
|
||||
raise HTTPException(status_code=404, detail="avatar not found")
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该分身")
|
||||
return avatar
|
||||
|
||||
|
||||
# ---------------- Documents ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/docs")
|
||||
def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
docs = (
|
||||
db.query(KnowledgeDoc)
|
||||
.filter(KnowledgeDoc.avatar_id == avatar_id)
|
||||
.order_by(KnowledgeDoc.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return ok([d.to_dict() for d in docs])
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/docs")
|
||||
async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
return fail(f"不支持的文件类型:{ext or '空'},仅支持 md/txt/pdf/doc/docx/xlsx", code=400)
|
||||
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||
os.makedirs(avatar_dir, exist_ok=True)
|
||||
stored = f"{uuid.uuid4().hex}{ext}"
|
||||
path = os.path.join(avatar_dir, stored)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
return fail("文件不能超过 10MB", code=400)
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
doc = KnowledgeDoc(
|
||||
avatar_id=avatar_id,
|
||||
filename=file.filename,
|
||||
file_type=ext.lstrip("."),
|
||||
file_size=len(content),
|
||||
file_url=f"/api/files/{avatar_id}/{stored}",
|
||||
status="parsing",
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片
|
||||
try:
|
||||
text = embeddings.extract_text(path, ext)
|
||||
chunks = embeddings.chunk_text(text)
|
||||
if chunks:
|
||||
vectors = embeddings.embed(chunks)
|
||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
||||
db.add(
|
||||
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.embedding_model = embeddings.MODEL
|
||||
doc.chunk_count = len(chunks)
|
||||
doc.vectorized_at = datetime.now(timezone.utc)
|
||||
doc.status = "ready"
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
except Exception as e:
|
||||
print("vectorize failed:", e)
|
||||
doc.status = "ready" # 上传成功但向量化失败,仍可展示
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
return ok(doc.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}/knowledge/docs/{doc_id}")
|
||||
def delete_doc(avatar_id: str, doc_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
doc = (
|
||||
db.query(KnowledgeDoc)
|
||||
.filter(KnowledgeDoc.id == doc_id, KnowledgeDoc.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not doc:
|
||||
return fail("文档不存在", code=404)
|
||||
# 级联删除切片
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc_id).delete()
|
||||
try:
|
||||
fp = os.path.join(UPLOAD_DIR, avatar_id, os.path.basename(doc.file_url))
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
except Exception:
|
||||
pass
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
return ok({"id": doc_id})
|
||||
|
||||
|
||||
# ---------------- 向量检索 ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/search")
|
||||
def search_knowledge(avatar_id: str, q: str = "", top_k: int = 5, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (q or "").strip()
|
||||
if not q:
|
||||
return ok([])
|
||||
chunks = (
|
||||
db.query(KnowledgeChunk)
|
||||
.filter(KnowledgeChunk.avatar_id == avatar_id)
|
||||
.all()
|
||||
)
|
||||
if not chunks:
|
||||
return ok([])
|
||||
qvec = embeddings.embed([q])[0]
|
||||
scored = []
|
||||
for c in chunks:
|
||||
try:
|
||||
vec = json.loads(c.vector)
|
||||
except Exception:
|
||||
continue
|
||||
scored.append((embeddings.cosine(qvec, vec), c))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = []
|
||||
for score, c in scored[: max(1, top_k)]:
|
||||
doc = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == c.doc_id).first()
|
||||
snippet = c.content[:120] + ("…" if len(c.content) > 120 else "")
|
||||
results.append(
|
||||
{
|
||||
"docId": c.doc_id,
|
||||
"filename": doc.filename if doc else "",
|
||||
"fileType": doc.file_type if doc else "",
|
||||
"snippet": snippet,
|
||||
"score": round(score, 4),
|
||||
}
|
||||
)
|
||||
return ok(results)
|
||||
|
||||
|
||||
# ---------------- Standard Q&A pairs ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/qa")
|
||||
def list_qa(avatar_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
items = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.avatar_id == avatar_id)
|
||||
.order_by(QAPair.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return ok([q.to_dict() for q in items])
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/qa")
|
||||
def create_qa(avatar_id: str, body: QAIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = QAPair(
|
||||
avatar_id=avatar_id,
|
||||
question=body.question,
|
||||
answer=body.answer,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
db.add(q)
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/knowledge/qa/{qa_id}")
|
||||
def update_qa(avatar_id: str, qa_id: str, body: QAIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
q.question = body.question
|
||||
q.answer = body.answer
|
||||
q.enabled = body.enabled
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/knowledge/qa/{qa_id}/enabled")
|
||||
def set_qa_enabled(avatar_id: str, qa_id: str, body: EnabledIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
q.enabled = bool(body.enabled)
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}/knowledge/qa/{qa_id}")
|
||||
def delete_qa(avatar_id: str, qa_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
db.delete(q)
|
||||
db.commit()
|
||||
return ok({"id": qa_id})
|
||||
|
||||
|
||||
# ---------------- HuiHui user profile (mock; plug real interface via HUIHUI_USER_API) ----------------
|
||||
@router.get("/user/profile")
|
||||
def user_profile():
|
||||
# 接入真实会会接口:设置环境变量 HUIHUI_USER_API 后在此请求并映射字段
|
||||
api = os.getenv("HUIHUI_USER_API")
|
||||
if api:
|
||||
# TODO: 调用会会用户接口,返回 { userId, nickname, avatarUrl }
|
||||
pass
|
||||
return ok({
|
||||
"userId": "hh_10001",
|
||||
"nickname": "会会用户",
|
||||
"avatarUrl": "https://api.dicebear.com/7.x/initials/svg?seed=HuiHui&backgroundColor=F97316",
|
||||
})
|
||||
35
digital-avatar-app/backend/routers/organizations.py
Normal file
35
digital-avatar-app/backend/routers/organizations.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Organization
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["组织"])
|
||||
|
||||
|
||||
@router.get("/organizations")
|
||||
def list_orgs(page: int = 1, limit: int = 20, db: Session = Depends(get_db)):
|
||||
q = db.query(Organization)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(Organization.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return ok({"data": [o.to_dict() for o in items], "total": total})
|
||||
|
||||
|
||||
@router.post("/organizations")
|
||||
def create_org(payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
o = Organization(
|
||||
name=payload.get("name", "未命名组织"),
|
||||
description=payload.get("desc", "") or payload.get("description", ""),
|
||||
emoji=payload.get("emoji", "🏢"),
|
||||
org_type=payload.get("type", "") or payload.get("orgType", "team"),
|
||||
)
|
||||
db.add(o)
|
||||
db.commit()
|
||||
db.refresh(o)
|
||||
return ok(o.to_dict())
|
||||
37
digital-avatar-app/backend/routers/tokens.py
Normal file
37
digital-avatar-app/backend/routers/tokens.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import TokenAccount, TokenPlan
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["Token"])
|
||||
|
||||
|
||||
@router.get("/token/balance")
|
||||
def balance(db: Session = Depends(get_db)):
|
||||
acc = db.query(TokenAccount).first()
|
||||
return ok({"balance": acc.balance if acc else 0})
|
||||
|
||||
|
||||
@router.get("/token/plans")
|
||||
def plans(db: Session = Depends(get_db)):
|
||||
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
||||
return ok([p.to_dict() for p in items])
|
||||
|
||||
|
||||
@router.post("/token/charge")
|
||||
def charge(payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
plan_id = payload.get("planId")
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
return fail("套餐不存在", 404)
|
||||
acc = db.query(TokenAccount).first()
|
||||
if not acc:
|
||||
acc = TokenAccount(balance=0)
|
||||
db.add(acc)
|
||||
db.commit()
|
||||
db.refresh(acc)
|
||||
acc.balance += plan.amount
|
||||
db.commit()
|
||||
return ok({"balance": acc.balance, "charged": plan.amount})
|
||||
1
digital-avatar-app/backend/tests/__init__.py
Normal file
1
digital-avatar-app/backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
95
digital-avatar-app/backend/tests/test_chat_orchestration.py
Normal file
95
digital-avatar-app/backend/tests/test_chat_orchestration.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models import Avatar, User
|
||||
from routers.chat import _build_prompt, _match_standard_qa, _require_owned_avatar, _resolve_reply
|
||||
|
||||
|
||||
class ChatOrchestrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.avatar = SimpleNamespace(
|
||||
id="avatar-1",
|
||||
owner_id="huihui-user-1",
|
||||
config={
|
||||
"replyStyle": "professional",
|
||||
"creativity": 50,
|
||||
"rigor": 80,
|
||||
"humor": 20,
|
||||
"responseLength": "medium",
|
||||
"systemPrompt": "不要编造政策。",
|
||||
},
|
||||
)
|
||||
self.qa = SimpleNamespace(question="公司地址?", answer="标准地址", enabled=True)
|
||||
self.disabled_qa = SimpleNamespace(question="公司地址?", answer="错误答案", enabled=False)
|
||||
|
||||
def test_enabled_qa_wins_without_calling_model(self):
|
||||
fake_model = Mock()
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
" 公司地址? ",
|
||||
[],
|
||||
qa_pairs=[self.disabled_qa, self.qa],
|
||||
search_fn=lambda *_args, **_kwargs: [],
|
||||
model_client=fake_model,
|
||||
)
|
||||
self.assertEqual(result["source"], "qa")
|
||||
self.assertEqual(result["answer"], "标准地址")
|
||||
fake_model.assert_not_called()
|
||||
|
||||
def test_knowledge_context_is_sent_to_qwen_after_qa_miss(self):
|
||||
fake_model = Mock(return_value="根据知识库内容回答")
|
||||
knowledge_hit = {
|
||||
"filename": "退款.md",
|
||||
"snippet": "知识库内容:七日内可申请退款。",
|
||||
"score": 0.92,
|
||||
}
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
"退款规则",
|
||||
[],
|
||||
qa_pairs=[],
|
||||
search_fn=lambda *_args, **_kwargs: [knowledge_hit],
|
||||
model_client=fake_model,
|
||||
)
|
||||
self.assertEqual(result["source"], "knowledge")
|
||||
self.assertIn("知识库内容", fake_model.call_args.kwargs["messages"][0]["content"])
|
||||
|
||||
def test_prompt_contains_personality_configuration(self):
|
||||
messages = _build_prompt(self.avatar, [], "你好", [])
|
||||
self.assertIn("严谨度", messages[0]["content"])
|
||||
self.assertIn("不要编造政策", messages[0]["content"])
|
||||
|
||||
def test_chat_rejects_avatar_owned_by_another_user(self):
|
||||
class Query:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.value
|
||||
|
||||
self_avatar = self.avatar
|
||||
|
||||
class DB:
|
||||
avatar = self_avatar
|
||||
|
||||
def query(self, model):
|
||||
return Query(
|
||||
self.avatar if model is Avatar else SimpleNamespace(huihui_user_id="huihui-user-2")
|
||||
)
|
||||
|
||||
db = DB()
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
_require_owned_avatar(db, self.avatar.id, "Bearer other-token")
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
32
digital-avatar-app/backend/tests/test_embeddings.py
Normal file
32
digital-avatar-app/backend/tests/test_embeddings.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import embeddings
|
||||
|
||||
|
||||
class TextExtractionTests(unittest.TestCase):
|
||||
def write_text(self, suffix, content):
|
||||
handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
handle.close()
|
||||
self.addCleanup(lambda: os.path.exists(handle.name) and os.unlink(handle.name))
|
||||
with open(handle.name, "w", encoding="utf-8") as stream:
|
||||
stream.write(content)
|
||||
return handle.name
|
||||
|
||||
def test_extracts_utf8_markdown(self):
|
||||
path = self.write_text(".md", "# 退款规则\n\n七日内可申请退款。")
|
||||
self.assertEqual(embeddings.extract_text(path, ".md"), "# 退款规则\n\n七日内可申请退款。")
|
||||
|
||||
def test_extracts_utf8_text(self):
|
||||
path = self.write_text(".txt", "客服热线:400-123-4567")
|
||||
self.assertEqual(embeddings.extract_text(path, ".txt"), "客服热线:400-123-4567")
|
||||
|
||||
def test_rejects_unsupported_extension(self):
|
||||
path = self.write_text(".csv", "not supported")
|
||||
with self.assertRaises(ValueError):
|
||||
embeddings.extract_text(path, ".csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
31
digital-avatar-app/docker-compose.yml
Normal file
31
digital-avatar-app/docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
|
||||
services:
|
||||
avatar-backend:
|
||||
build: ./backend
|
||||
image: avatar-test-backend:latest
|
||||
container_name: avatar-test-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
avatar-frontend:
|
||||
build: .
|
||||
image: avatar-test-frontend:latest
|
||||
container_name: avatar-test-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8099:80" # 浏览器访问 http://<host>:8099
|
||||
depends_on:
|
||||
- avatar-backend
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
networks:
|
||||
avatar-net:
|
||||
driver: bridge
|
||||
22
digital-avatar-app/index.html
Normal file
22
digital-avatar-app/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>会会数字分身</title>
|
||||
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 -->
|
||||
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
||||
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
|
||||
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
|
||||
<script type="text/javascript">
|
||||
window.__APP_CONFIG__ = { apiBase: '' }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
digital-avatar-app/knowledge-samples/牙齿防护知识手册.md
Normal file
50
digital-avatar-app/knowledge-samples/牙齿防护知识手册.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 牙齿防护知识手册
|
||||
|
||||
> 本手册用于「会会」数字分身知识库,覆盖日常护牙、牙线使用、饮食护牙、定期检查、常见口腔问题及儿童口腔保健,供分身专业应答引用。
|
||||
|
||||
## 一、日常刷牙
|
||||
|
||||
- **频率与时机**:每天早晚各刷一次,睡前刷牙尤为重要;饭后建议漱口,进食酸性食物后等待约 30 分钟再刷牙,避免即刻磨损被酸软化的牙釉质。
|
||||
- **巴氏刷牙法(Bass)**:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤 10 次左右,再向牙冠方向拂刷;依次覆盖牙齿外侧、内侧与咬合面。
|
||||
- **时长**:每次刷牙至少 2 分钟。
|
||||
- **工具**:选用软毛、小头牙刷;刷毛外翻或每 3 个月更换一次。
|
||||
- **牙膏**:推荐使用含氟牙膏,氟可增强牙釉质抗酸与再矿化能力。
|
||||
|
||||
## 二、牙线 / 牙缝清洁
|
||||
|
||||
- 每天至少使用一次牙线,清洁牙刷难以到达的牙齿邻面。
|
||||
- 取约 45cm 牙线,以 C 形包绕牙面,上下刮擦清除菌斑。
|
||||
- 牙缝较大或牙周炎人群,可配合使用牙间刷(间隙刷)。
|
||||
|
||||
## 三、饮食与护牙
|
||||
|
||||
- 控制游离糖摄入,少喝含糖饮料、少吃黏性甜食——糖是致龋的主要元凶。
|
||||
- 酸性饮料(碳酸饮料、果汁)会侵蚀牙釉质,建议用吸管饮用并尽快漱口,勿立即刷牙。
|
||||
- 多喝水,唾液具有自我清洁与再矿化作用。
|
||||
- 适量补充钙、磷、维生素 D(奶类、豆制品、深绿色蔬菜)。
|
||||
|
||||
## 四、定期检查与洁治
|
||||
|
||||
- 建议每 6 个月进行一次口腔检查与洁牙(洗牙)。
|
||||
- 洗牙清除牙石与菌斑,预防牙龈炎、牙周炎;**洗牙不会让牙缝变大**(牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成)。
|
||||
- 出现龋齿、牙龈出血、口臭、牙齿敏感应及时就医,越早处理越简单。
|
||||
|
||||
## 五、常见口腔问题
|
||||
|
||||
- **龋齿(蛀牙)**:浅龋可涂氟干预,但已形成龋洞必须充填,无法自愈。
|
||||
- **牙周病**:表现为牙龈红肿、出血、口臭、牙齿松动;基础治疗为洁治 + 龈下刮治,需长期维护。
|
||||
- **牙齿敏感**:遇冷热酸甜刺激痛,可用抗敏感牙膏;若持续加重需就诊排查楔状缺损或牙龈退缩。
|
||||
- **智齿**:阻生或反复发炎的智齿建议拔除。
|
||||
|
||||
## 六、儿童口腔保健
|
||||
|
||||
- 第一颗乳牙萌出(约 6 月龄)即开始清洁,可用纱布或指套牙刷。
|
||||
- **含氟牙膏用量**:3 岁以下用米粒大小,3–6 岁用豌豆大小,均需在家长监督下使用并防止误吞。
|
||||
- **窝沟封闭**:6–7 岁六龄齿萌出后做窝沟封闭,有效预防窝沟龋。
|
||||
- 定期涂氟,每半年一次口腔检查。
|
||||
|
||||
## 七、常见误区澄清
|
||||
|
||||
- 误区「洗牙伤牙」:事实为规范洗牙安全,不损伤牙釉质。
|
||||
- 误区「牙龈出血就不能刷」:事实为出血多因炎症,更应保持清洁并尽早就医。
|
||||
- 误区「乳牙坏了不用管」:事实为乳牙健康直接影响恒牙与颌骨发育。
|
||||
62
digital-avatar-app/knowledge-samples/牙齿防护问答对.json
Normal file
62
digital-avatar-app/knowledge-samples/牙齿防护问答对.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"question": "每天应该刷牙几次?每次刷多久?",
|
||||
"answer": "建议每天早晚各刷一次,每次至少 2 分钟,并使用含氟牙膏;睡前那次尤为重要。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "正确的刷牙方法是什么?",
|
||||
"answer": "推荐巴氏刷牙法:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤约 10 次后再向牙冠方向拂刷,依次清洁牙齿外侧、内侧和咬合面。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "电动牙刷比手动牙刷更好吗?",
|
||||
"answer": "两者清洁效果相当,关键在于正确刷牙方法;电动牙刷更易保证刷牙时长,对刷牙不到位的人更友好。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "刷牙总出血是怎么回事?",
|
||||
"answer": "多为牙龈炎或牙周炎所致,建议尽快洗牙并就医检查,同时坚持正确刷牙和使用牙线,切勿因出血而停止刷牙。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "牙线必须每天用吗?怎么用?",
|
||||
"answer": "建议每天至少使用一次牙线清洁牙齿邻面。取约 45cm 牙线,以 C 形包绕牙面上下刮擦;牙缝较大者可配合牙间刷。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "蛀牙(龋齿)能自己好吗?",
|
||||
"answer": "浅龋可通过涂氟减缓进展,但已形成龋洞必须补牙,无法自愈;越早处理越简单、损伤越小。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "洗牙会让牙缝变大、伤牙齿吗?",
|
||||
"answer": "规范的洗牙安全且不损伤牙釉质。洗牙清除的是牙石,牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "牙齿遇冷热酸甜就酸痛,怎么办?",
|
||||
"answer": "属牙齿敏感,可先使用抗敏感牙膏;若持续加重,需就诊排查楔状缺损或牙龈退缩等成因,再对症处理。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "孩子几岁开始用含氟牙膏?用多少?",
|
||||
"answer": "第一颗乳牙萌出后即可在家长帮助下刷牙;3 岁以下用米粒大小含氟牙膏,3–6 岁用豌豆大小,均需监督防误吞。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "儿童六龄齿需要做窝沟封闭吗?",
|
||||
"answer": "建议 6–7 岁六龄齿萌出后做窝沟封闭,并定期涂氟、每半年口腔检查,可有效预防窝沟龋,保护恒牙。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "喝碳酸饮料会伤牙吗?怎么喝更好?",
|
||||
"answer": "碳酸饮料酸性强,会侵蚀牙釉质。建议用吸管减少接触、喝完尽快清水漱口,不要立即刷牙,并控制频次。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "多久做一次口腔检查和洗牙?",
|
||||
"answer": "建议每 6 个月进行一次口腔检查与洁牙,以便早期发现龋齿、牙周问题并及时干预。",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
38
digital-avatar-app/nginx.conf
Normal file
38
digital-avatar-app/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
# 完整主配置:覆盖 nginx:alpine 默认 /etc/nginx/nginx.conf
|
||||
# 新版 nginx 在受限容器内写 /run/nginx.pid 会报 Operation not permitted 并致命退出,
|
||||
# 这里把 pid 显式改到可写的 /tmp(main 上下文唯一一处),避免前端容器反复重启。
|
||||
pid /dev/null;
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA 兜底(hash 路由下深链接也可正常加载)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端 API:保留 /api 前缀转发到 avatar-backend:8000
|
||||
location /api/ {
|
||||
proxy_pass http://avatar-backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
1796
digital-avatar-app/package-lock.json
generated
Normal file
1796
digital-avatar-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
digital-avatar-app/package.json
Normal file
22
digital-avatar-app/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "digital-avatar-app",
|
||||
"version": "1.0.0",
|
||||
"description": "会会数字分身 Web App",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0"
|
||||
}
|
||||
}
|
||||
99
digital-avatar-app/scripts/avatar-page-data.test.mjs
Normal file
99
digital-avatar-app/scripts/avatar-page-data.test.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAvatarUpdatePayload,
|
||||
normalizeAvatarEditForm,
|
||||
pickAvatarId,
|
||||
unwrapListData,
|
||||
} from '../src/utils/avatar-page-data.js'
|
||||
|
||||
assert.deepEqual(unwrapListData([{ id: 'a1' }]), [{ id: 'a1' }], 'unwrapListData should return raw arrays')
|
||||
assert.deepEqual(
|
||||
unwrapListData({ data: [{ id: 'a2' }], total: 1 }),
|
||||
[{ id: 'a2' }],
|
||||
'unwrapListData should unwrap wrapped collection payloads'
|
||||
)
|
||||
assert.deepEqual(unwrapListData(null), [], 'unwrapListData should fall back to an empty list')
|
||||
|
||||
assert.equal(
|
||||
pickAvatarId('current-id', [{ id: 'first-id' }]),
|
||||
'current-id',
|
||||
'pickAvatarId should prefer current avatar id'
|
||||
)
|
||||
assert.equal(
|
||||
pickAvatarId('', [{ id: 'first-id' }]),
|
||||
'first-id',
|
||||
'pickAvatarId should fall back to the first avatar id'
|
||||
)
|
||||
assert.equal(pickAvatarId('', []), null, 'pickAvatarId should return null when no avatar exists')
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeAvatarEditForm({
|
||||
name: '我的分身',
|
||||
displayName: '会会助手',
|
||||
description: '描述',
|
||||
status: 'inactive',
|
||||
photoUrl: 'https://img.example/avatar.png',
|
||||
config: { replyStyle: 'friendly', creativity: 72, rigor: 88, humor: 16, responseLength: 'short', systemPrompt: '不要编造', autoReply: false },
|
||||
}),
|
||||
{
|
||||
name: '我的分身',
|
||||
displayName: '会会助手',
|
||||
description: '描述',
|
||||
status: 'inactive',
|
||||
photoUrl: 'https://img.example/avatar.png',
|
||||
replyStyle: 'friendly',
|
||||
creativity: 72,
|
||||
rigor: 88,
|
||||
humor: 16,
|
||||
responseLength: 'short',
|
||||
systemPrompt: '不要编造',
|
||||
autoReply: false,
|
||||
},
|
||||
'normalizeAvatarEditForm should map API avatars into edit form state'
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
buildAvatarUpdatePayload({
|
||||
name: '更新后的分身',
|
||||
displayName: '更新后的助手',
|
||||
description: '新描述',
|
||||
status: 'active',
|
||||
photoUrl: 'https://img.example/new-avatar.png',
|
||||
replyStyle: 'casual',
|
||||
creativity: 65,
|
||||
rigor: 70,
|
||||
humor: 25,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: '回答简洁',
|
||||
autoReply: true,
|
||||
}),
|
||||
{
|
||||
name: '更新后的分身',
|
||||
displayName: '更新后的助手',
|
||||
description: '新描述',
|
||||
status: 'active',
|
||||
photoUrl: 'https://img.example/new-avatar.png',
|
||||
config: {
|
||||
replyStyle: 'casual',
|
||||
creativity: 65,
|
||||
rigor: 70,
|
||||
humor: 25,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: '回答简洁',
|
||||
autoReply: true,
|
||||
},
|
||||
},
|
||||
'buildAvatarUpdatePayload should emit API-ready update payloads'
|
||||
)
|
||||
|
||||
const knowledgeView = fs.readFileSync(path.resolve('src/views/KnowledgeManage.vue'), 'utf8')
|
||||
assert.match(knowledgeView, /文档知识库/, 'knowledge page should expose the document tab')
|
||||
assert.match(knowledgeView, /标准问答对/, 'knowledge page should expose the QA tab')
|
||||
assert.match(knowledgeView, /activeTab/, 'knowledge page should switch active tabs')
|
||||
assert.match(knowledgeView, /accept="\.md,\.txt,\.pdf,\.doc,\.docx,\.xlsx"/, 'knowledge page should accept md and txt')
|
||||
assert.match(knowledgeView, /table-scroll/, 'knowledge page should use a scrollable table wrapper')
|
||||
|
||||
console.log('avatar-page-data tests passed')
|
||||
126
digital-avatar-app/src/App.vue
Normal file
126
digital-avatar-app/src/App.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
<!-- 底部导航栏 -->
|
||||
<nav class="bottom-nav" v-if="showNav">
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: currentRoute === '/' || currentRoute === '/avatar/manage' }"
|
||||
@click="navigateTo('/avatar/manage')"
|
||||
>
|
||||
<span class="nav-icon">🤖</span>
|
||||
<span class="nav-label">我的分身</span>
|
||||
</button>
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: currentRoute === '/authorization' }"
|
||||
@click="navigateTo('/authorization')"
|
||||
>
|
||||
<span class="nav-icon">🔑</span>
|
||||
<span class="nav-label">授权管理</span>
|
||||
</button>
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: currentRoute === '/token/charge' }"
|
||||
@click="navigateTo('/token/charge')"
|
||||
>
|
||||
<span class="nav-icon">💰</span>
|
||||
<span class="nav-label">Token</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const currentRoute = ref<string>(route.path)
|
||||
const showNav = ref<boolean>(shouldShowNav(route.path))
|
||||
|
||||
function shouldShowNav(path: string) {
|
||||
return path !== '/'
|
||||
&& path !== '/avatar/create'
|
||||
&& path !== '/login/sms'
|
||||
&& !path.startsWith('/avatar/edit')
|
||||
&& !path.startsWith('/avatar/chat')
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.path, (newPath) => {
|
||||
currentRoute.value = newPath
|
||||
showNav.value = shouldShowNav(newPath)
|
||||
})
|
||||
|
||||
// 导航
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentRoute.value = route.path
|
||||
showNav.value = shouldShowNav(route.path)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* 底部导航栏 */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
background: white;
|
||||
border-top: 1px solid #EDEEF1;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
color: #9398AE;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-label {
|
||||
color: #F97316;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon {
|
||||
filter: none;
|
||||
}
|
||||
</style>
|
||||
302
digital-avatar-app/src/api/index.ts
Normal file
302
digital-avatar-app/src/api/index.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
|
||||
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
|
||||
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
|
||||
// - web-view 内(混合架构):需配置为后端公网地址,例如 'https://geo.99hui.com/api'
|
||||
// - 同域部署的构建产物:可保持 '/api'
|
||||
function resolveBaseURL(): string {
|
||||
const cfg = (window as any).__APP_CONFIG__
|
||||
if (cfg && cfg.apiBase) return cfg.apiBase as string
|
||||
const env = import.meta.env.VITE_API_BASE as string | undefined
|
||||
if (env) return env
|
||||
return '/api'
|
||||
}
|
||||
|
||||
// 统一认证 token(由 uniapp 壳通过 URL 注入,见 utils/uniapp-bridge.ts)
|
||||
let _authToken: string | null = null
|
||||
export function setAuthToken(token: string | null) {
|
||||
_authToken = token
|
||||
}
|
||||
export function getAuthToken(): string | null {
|
||||
return _authToken
|
||||
}
|
||||
|
||||
// 创建 axios 实例(复用现有项目模式)
|
||||
const createRequest = (config?: AxiosRequestConfig): AxiosInstance => {
|
||||
const request = axios.create({
|
||||
baseURL: resolveBaseURL(),
|
||||
timeout: 30000,
|
||||
...config
|
||||
})
|
||||
|
||||
// 请求拦截器:注入认证头
|
||||
request.interceptors.request.use((cfg) => {
|
||||
if (_authToken) {
|
||||
cfg.headers = cfg.headers || {}
|
||||
;(cfg.headers as any).Authorization = `Bearer ${_authToken}`
|
||||
}
|
||||
return cfg
|
||||
})
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(res) => {
|
||||
const data = fixDatetimeTZ(res.data)
|
||||
if (data && data.code && data.code !== 200) {
|
||||
console.error('API Error:', data.message)
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
// 解包标准响应 { code, message, data }
|
||||
if (data && data.code === 200) {
|
||||
return data.data !== undefined ? data.data : data
|
||||
}
|
||||
return data
|
||||
},
|
||||
(err) => {
|
||||
const msg = err.response?.data?.detail || err.response?.data?.message || err.message || '网络错误'
|
||||
console.error('Network Error:', msg)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
// 递归修复时区标识(复用现有项目逻辑)
|
||||
function fixDatetimeTZ(obj: any): any {
|
||||
if (typeof obj === 'string') {
|
||||
return obj.replace(/T(\d{2}:\d{2}:\d{2})\+00:00/g, 'T$1\+08:00')
|
||||
}
|
||||
if (Array.isArray(obj)) return obj.map(fixDatetimeTZ)
|
||||
if (obj && typeof obj === 'object') {
|
||||
const result: any = {}
|
||||
for (const k in obj) result[k] = fixDatetimeTZ(obj[k])
|
||||
return result
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
const request = createRequest()
|
||||
|
||||
// ==================== 分身管理 API ====================
|
||||
|
||||
export interface Avatar {
|
||||
id: string
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
photoUrl?: string
|
||||
status: 'active' | 'inactive' | 'training'
|
||||
tokenBalance: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 获取分身列表
|
||||
export const getAvatarList = (params?: { page?: number; limit?: number }) =>
|
||||
request.get<{ data: Avatar[]; total: number }>('/avatar', { params })
|
||||
|
||||
// 获取分身详情
|
||||
export const getAvatarDetail = (id: string) =>
|
||||
request.get<Avatar>(`/avatar/${id}`)
|
||||
|
||||
// 创建分身
|
||||
export const createAvatar = (data: Partial<Avatar>) =>
|
||||
request.post<Avatar>('/avatar', data)
|
||||
|
||||
// 更新分身
|
||||
export const updateAvatar = (id: string, data: Partial<Avatar>) =>
|
||||
request.put<Avatar>(`/avatar/${id}`, data)
|
||||
|
||||
// 删除分身
|
||||
export const deleteAvatar = (id: string) =>
|
||||
request.delete(`/avatar/${id}`)
|
||||
|
||||
// ==================== Token 管理 API ====================
|
||||
|
||||
// 获取 Token 余额
|
||||
export const getTokenBalance = () =>
|
||||
request.get<{ balance: number }>('/token/balance')
|
||||
|
||||
// 获取充值套餐
|
||||
export const getRechargePlans = () =>
|
||||
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
|
||||
|
||||
// 执行充值
|
||||
export const chargeToken = (planId: string) =>
|
||||
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
|
||||
|
||||
// ==================== 授权管理 API ====================
|
||||
|
||||
export interface Authorization {
|
||||
id: string
|
||||
avatarId: string
|
||||
targetType: 'user' | 'organization' | 'application'
|
||||
targetId: string
|
||||
targetName: string
|
||||
permissions: string[]
|
||||
status: 'active' | 'inactive'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 获取授权列表
|
||||
export const getAuthorizationList = (avatarId: string) =>
|
||||
request.get<Authorization[]>(`/avatar/${avatarId}/authorizations`)
|
||||
|
||||
// 更新授权
|
||||
export const updateAuthorization = (avatarId: string, data: Partial<Authorization>) =>
|
||||
request.put(`/avatar/${avatarId}/authorizations`, data)
|
||||
|
||||
// ==================== 组织管理 API ====================
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
role: 'admin' | 'member' | 'viewer'
|
||||
memberCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 获取组织列表
|
||||
export const getOrganizationList = (params?: any) =>
|
||||
request.get<{ data: Organization[]; total: number }>('/organizations', { params })
|
||||
|
||||
// 创建组织
|
||||
export const createOrganization = (data: Partial<Organization>) =>
|
||||
request.post<Organization>('/organizations', data)
|
||||
|
||||
// ==================== 知识库管理 API ====================
|
||||
|
||||
export interface KnowledgeDoc {
|
||||
id: string
|
||||
avatarId: string
|
||||
filename: string
|
||||
fileType: string
|
||||
fileSize: number
|
||||
fileUrl: string
|
||||
status: string
|
||||
vectorized?: boolean
|
||||
embeddingModel?: string
|
||||
chunkCount?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface QAPair {
|
||||
id: string
|
||||
avatarId: string
|
||||
question: string
|
||||
answer: string
|
||||
enabled?: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
docId: string
|
||||
filename: string
|
||||
fileType: string
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
// 文档列表
|
||||
export const getKnowledgeDocs = (avatarId: string) =>
|
||||
request.get<KnowledgeDoc[]>(`/avatar/${avatarId}/knowledge/docs`)
|
||||
|
||||
// 上传文档(支持 md/txt/pdf/doc/docx/xlsx)
|
||||
export const uploadKnowledgeDoc = (avatarId: string, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
// 删除文档
|
||||
export const deleteKnowledgeDoc = (avatarId: string, docId: string) =>
|
||||
request.delete(`/avatar/${avatarId}/knowledge/docs/${docId}`)
|
||||
|
||||
// 标准问答对列表
|
||||
export const getQAPairs = (avatarId: string) =>
|
||||
request.get<QAPair[]>(`/avatar/${avatarId}/knowledge/qa`)
|
||||
|
||||
// 创建问答对
|
||||
export const createQAPair = (avatarId: string, data: { question: string; answer: string; enabled?: boolean }) =>
|
||||
request.post<QAPair>(`/avatar/${avatarId}/knowledge/qa`, data)
|
||||
|
||||
// 更新问答对
|
||||
export const updateQAPair = (avatarId: string, id: string, data: { question: string; answer: string; enabled?: boolean }) =>
|
||||
request.put<QAPair>(`/avatar/${avatarId}/knowledge/qa/${id}`, data)
|
||||
|
||||
// 切换问答对启用状态
|
||||
export const setQaEnabled = (avatarId: string, id: string, enabled: boolean) =>
|
||||
request.put<QAPair>(`/avatar/${avatarId}/knowledge/qa/${id}/enabled`, { enabled })
|
||||
|
||||
// 删除问答对
|
||||
export const deleteQAPair = (avatarId: string, id: string) =>
|
||||
request.delete(`/avatar/${avatarId}/knowledge/qa/${id}`)
|
||||
|
||||
// 文档向量检索
|
||||
export const searchKnowledge = (avatarId: string, q: string, topK = 5) =>
|
||||
request.get<SearchResult[]>(`/avatar/${avatarId}/knowledge/search`, {
|
||||
params: { q, top_k: topK }
|
||||
})
|
||||
|
||||
// ==================== 数字分身聊天 API ====================
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
answer: string
|
||||
source: 'qa' | 'knowledge' | 'qwen'
|
||||
references?: Array<{ docId?: string; filename?: string; fileType?: string; snippet?: string; score?: number }>
|
||||
}
|
||||
|
||||
export const sendAvatarChat = (avatarId: string, payload: { message: string; history?: ChatMessage[] }) =>
|
||||
request.post<ChatResponse>(`/avatar/${avatarId}/chat`, payload)
|
||||
|
||||
// ==================== 会会用户资料 API ====================
|
||||
|
||||
export interface UserProfile {
|
||||
userId: string
|
||||
nickname: string
|
||||
avatarUrl: string
|
||||
}
|
||||
|
||||
// 获取会会系统中的用户头像与昵称
|
||||
export const getUserProfile = () =>
|
||||
request.get<UserProfile>('/user/profile')
|
||||
|
||||
// ==================== 会会短信验证码登录 API ====================
|
||||
|
||||
export interface SmsLoginResult {
|
||||
token: string
|
||||
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
|
||||
huihui: { userId: string; nickname: string; avatarUrl: string; token: string }
|
||||
}
|
||||
|
||||
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
|
||||
export const sendSmsCode = (phone: string) =>
|
||||
request.post<{ sent: boolean; devCode?: string; dev?: boolean }>('/huihui/sms/send', { phone })
|
||||
|
||||
// 短信验证码登录
|
||||
export const loginBySms = (phone: string, code: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/sms/login', { phone, code })
|
||||
|
||||
// 账号密码登录(会会 loginType=password)
|
||||
export const loginByPassword = (account: string, password: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
|
||||
|
||||
// 当前登录用户
|
||||
export const getCurrentUser = () =>
|
||||
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
|
||||
|
||||
// 退出登录
|
||||
export const logoutUser = () =>
|
||||
request.post('/huihui/logout')
|
||||
|
||||
export default request
|
||||
55
digital-avatar-app/src/main.ts
Normal file
55
digital-avatar-app/src/main.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './store'
|
||||
import { getLaunchParams, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { setAuthToken } from '@/api'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
|
||||
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
||||
const params = getLaunchParams()
|
||||
const avatarStore = useAvatarStore(pinia)
|
||||
const userStore = useUserStore(pinia)
|
||||
|
||||
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户)
|
||||
userStore.loadFromStorage()
|
||||
if (userStore.isLogin && userStore.user) {
|
||||
setAuthToken(userStore.token)
|
||||
avatarStore.setNativeProfile({
|
||||
userId: (userStore.user as any).huihuiUserId || '',
|
||||
nickname: userStore.user.nickname || '',
|
||||
avatarUrl: userStore.user.avatarUrl || ''
|
||||
})
|
||||
}
|
||||
|
||||
if (params.token) {
|
||||
setAuthToken(params.token)
|
||||
}
|
||||
if (params.userId || params.nickname || params.avatar) {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: params.userId || '',
|
||||
nickname: params.nickname || '',
|
||||
avatarUrl: params.avatar || ''
|
||||
})
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
|
||||
onNativeMessage((msg) => {
|
||||
if (!msg || !msg.type) return
|
||||
if (msg.type === 'tokenRefresh' && msg.token) {
|
||||
setAuthToken(msg.token)
|
||||
}
|
||||
if (msg.type === 'userUpdate' && msg.user) {
|
||||
avatarStore.setNativeProfile(msg.user)
|
||||
}
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 通知原生壳:H5 已就绪
|
||||
UniEvents.ready()
|
||||
115
digital-avatar-app/src/router/index.ts
Normal file
115
digital-avatar-app/src/router/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { getAuthToken } from '@/api'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'AvatarHome',
|
||||
redirect: '/avatar/manage',
|
||||
meta: { title: '数字分身管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/create',
|
||||
name: 'AvatarCreate',
|
||||
component: () => import('@/views/AvatarCreate.vue'),
|
||||
meta: { title: '创建数字分身', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/manage',
|
||||
name: 'AvatarManage',
|
||||
component: () => import('@/views/AvatarManage.vue'),
|
||||
meta: { title: '数字分身管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/edit/:id',
|
||||
name: 'AvatarEdit',
|
||||
component: () => import('@/views/AvatarEdit.vue'),
|
||||
meta: { title: '形象微调编辑', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/chat/:id',
|
||||
name: 'AvatarChat',
|
||||
component: () => import('@/views/AvatarChat.vue'),
|
||||
meta: { title: '和分身对话', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/authorization',
|
||||
name: 'AuthorizationManage',
|
||||
component: () => import('@/views/AuthorizationManage.vue'),
|
||||
meta: { title: '授权管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/token/charge',
|
||||
name: 'TokenCharge',
|
||||
component: () => import('@/views/TokenCharge.vue'),
|
||||
meta: { title: 'Token充值', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/card',
|
||||
name: 'AvatarCard',
|
||||
component: () => import('@/views/AvatarCard.vue'),
|
||||
meta: { title: '分身名片', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/contacts',
|
||||
name: 'AvatarContacts',
|
||||
component: () => import('@/views/AvatarContacts.vue'),
|
||||
meta: { title: '分身人脉', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/projects',
|
||||
name: 'MyProjects',
|
||||
component: () => import('@/views/MyProjects.vue'),
|
||||
meta: { title: '我的项目', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/org/create',
|
||||
name: 'CreateOrg',
|
||||
component: () => import('@/views/CreateOrg.vue'),
|
||||
meta: { title: '创建组织', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/knowledge',
|
||||
name: 'KnowledgeManage',
|
||||
component: () => import('@/views/KnowledgeManage.vue'),
|
||||
meta: { title: '知识库管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/knowledge/qa/create',
|
||||
name: 'QaPairCreate',
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '添加问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/knowledge/qa/:qaId/edit',
|
||||
name: 'QaPairEdit',
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '编辑问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login/sms',
|
||||
name: 'SmsLogin',
|
||||
component: () => import('@/views/SmsLogin.vue'),
|
||||
meta: { title: '短信验证码登录' }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
// 混合架构下 web-view 内用 hash 模式,原生返回键与深链更稳
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title as string || '会会数字分身'
|
||||
const hasLocalSession = Boolean(localStorage.getItem('hh_app_token'))
|
||||
const hasInjectedSession = Boolean(getAuthToken())
|
||||
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
|
||||
next({ path: '/login/sms', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
95
digital-avatar-app/src/store/avatar.ts
Normal file
95
digital-avatar-app/src/store/avatar.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getUserProfile } from '@/api'
|
||||
import { unwrapListData } from '@/utils/avatar-page-data'
|
||||
|
||||
export const useAvatarStore = defineStore('avatar', () => {
|
||||
// 已创建的分身列表(来自后端)
|
||||
const avatars = ref<any[]>([])
|
||||
// 全局 Token 余额(来自后端)
|
||||
const tokenBalance = ref<number>(0)
|
||||
// 当前选中分身 id
|
||||
const currentAvatarId = ref<string | null>(null)
|
||||
// 会会用户资料(头像/昵称,来自会会接口)
|
||||
const userProfile = ref<any>(null)
|
||||
|
||||
// 拉取分身列表
|
||||
const loadAvatars = async () => {
|
||||
try {
|
||||
const res = await getAvatarList()
|
||||
avatars.value = unwrapListData(res)
|
||||
if (avatars.value.length) currentAvatarId.value = avatars.value[0].id
|
||||
} catch (e) {
|
||||
console.error('加载分身失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取 Token 余额
|
||||
const loadTokenBalance = async () => {
|
||||
try {
|
||||
const res = await getTokenBalance()
|
||||
tokenBalance.value = (res as any)?.balance ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取会会用户资料(头像/昵称)
|
||||
const loadUserProfile = async () => {
|
||||
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
|
||||
if (userProfile.value && (userProfile.value as any).__native) return
|
||||
try {
|
||||
const res = await getUserProfile()
|
||||
userProfile.value = res as any
|
||||
} catch (e) {
|
||||
console.error('加载用户资料失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 由 uniapp 壳注入的会会资料(混合架构),标记为原生来源
|
||||
const setNativeProfile = (profile: { userId?: string; nickname?: string; avatarUrl?: string }) => {
|
||||
userProfile.value = {
|
||||
userId: profile.userId || '',
|
||||
nickname: profile.nickname || '',
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
__native: true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建分身(写入后端)
|
||||
const addAvatar = async (avatar: any) => {
|
||||
const res: any = await apiCreate(avatar)
|
||||
const created = res?.data ? res.data : res
|
||||
avatars.value.unshift(created)
|
||||
currentAvatarId.value = created.id
|
||||
return created
|
||||
}
|
||||
|
||||
// 删除分身(写入后端,级联清理关联数据)
|
||||
const removeAvatar = async (id: string) => {
|
||||
await apiDelete(id)
|
||||
avatars.value = avatars.value.filter((a) => a.id !== id)
|
||||
if (currentAvatarId.value === id) {
|
||||
currentAvatarId.value = avatars.value[0]?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
// 同步余额(充值后)
|
||||
const setTokenBalance = (balance: number) => {
|
||||
tokenBalance.value = balance
|
||||
}
|
||||
|
||||
return {
|
||||
avatars,
|
||||
tokenBalance,
|
||||
currentAvatarId,
|
||||
userProfile,
|
||||
loadAvatars,
|
||||
loadTokenBalance,
|
||||
loadUserProfile,
|
||||
setNativeProfile,
|
||||
addAvatar,
|
||||
removeAvatar,
|
||||
setTokenBalance
|
||||
}
|
||||
})
|
||||
5
digital-avatar-app/src/store/index.ts
Normal file
5
digital-avatar-app/src/store/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
82
digital-avatar-app/src/store/user.ts
Normal file
82
digital-avatar-app/src/store/user.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { setAuthToken, sendSmsCode, loginBySms, loginByPassword, logoutUser, type UserProfile } from '@/api'
|
||||
|
||||
const TOKEN_KEY = 'hh_app_token'
|
||||
const USER_KEY = 'hh_app_user'
|
||||
|
||||
// 会会短信登录的本地会话(打通 会会 userId ↔ 本系统用户)
|
||||
export const useUserStore = defineStore('smsuser', () => {
|
||||
const token = ref<string>('')
|
||||
const user = ref<(UserProfile & { huihuiUserId?: string; phone?: string }) | null>(null)
|
||||
const isLogin = ref(false)
|
||||
|
||||
// 启动时从本地恢复会话
|
||||
const loadFromStorage = () => {
|
||||
const t = localStorage.getItem(TOKEN_KEY)
|
||||
const u = localStorage.getItem(USER_KEY)
|
||||
if (t && u) {
|
||||
try {
|
||||
token.value = t
|
||||
user.value = JSON.parse(u)
|
||||
isLogin.value = true
|
||||
setAuthToken(t)
|
||||
} catch {
|
||||
clearLocal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persist = () => {
|
||||
localStorage.setItem(TOKEN_KEY, token.value)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user.value))
|
||||
}
|
||||
|
||||
const clearLocal = () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
// 发送验证码(返回结果,演示模式含 devCode)
|
||||
const sendCode = async (phone: string) => {
|
||||
return await sendSmsCode(phone)
|
||||
}
|
||||
|
||||
// 短信登录
|
||||
const login = async (phone: string, code: string) => {
|
||||
const res: any = await loginBySms(phone, code)
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
}
|
||||
|
||||
// 账号密码登录
|
||||
const loginByPwd = async (account: string, password: string) => {
|
||||
const res: any = await loginByPassword(account, password)
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
}
|
||||
|
||||
// 退出
|
||||
const logout = async () => {
|
||||
try {
|
||||
await logoutUser()
|
||||
} catch {
|
||||
/* 忽略网络错误,本地清除即可 */
|
||||
}
|
||||
token.value = ''
|
||||
user.value = null
|
||||
isLogin.value = false
|
||||
setAuthToken(null)
|
||||
clearLocal()
|
||||
}
|
||||
|
||||
return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout }
|
||||
})
|
||||
58
digital-avatar-app/src/styles/global.css
Normal file
58
digital-avatar-app/src/styles/global.css
Normal file
@@ -0,0 +1,58 @@
|
||||
/* 全局样式 - 会会数字分身 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #F97316;
|
||||
--primary-light: #FFF0E6;
|
||||
--text-primary: #18191C;
|
||||
--text-secondary: #9398AE;
|
||||
--bg-primary: #F8F9FA;
|
||||
--bg-white: #FFFFFF;
|
||||
--border-color: #EDEEF1;
|
||||
--success-color: #22C55E;
|
||||
--warning-color: #F59E0B;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #D1D5DB;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 通用工具类 */
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
46
digital-avatar-app/src/utils/avatar-page-data.js
Normal file
46
digital-avatar-app/src/utils/avatar-page-data.js
Normal file
@@ -0,0 +1,46 @@
|
||||
export function unwrapListData(value) {
|
||||
if (Array.isArray(value)) return value
|
||||
if (Array.isArray(value?.data)) return value.data
|
||||
return []
|
||||
}
|
||||
|
||||
export function pickAvatarId(currentAvatarId, avatars) {
|
||||
return currentAvatarId || avatars?.[0]?.id || null
|
||||
}
|
||||
|
||||
export function normalizeAvatarEditForm(avatar = {}) {
|
||||
const config = avatar.config || {}
|
||||
return {
|
||||
name: avatar.name || '',
|
||||
displayName: avatar.displayName || '',
|
||||
description: avatar.description || '',
|
||||
status: avatar.status || 'active',
|
||||
photoUrl: avatar.photoUrl || '',
|
||||
replyStyle: config.replyStyle || 'professional',
|
||||
creativity: Number.isFinite(config.creativity) ? config.creativity : 50,
|
||||
rigor: Number.isFinite(config.rigor) ? config.rigor : 50,
|
||||
humor: Number.isFinite(config.humor) ? config.humor : 30,
|
||||
responseLength: config.responseLength || 'medium',
|
||||
systemPrompt: config.systemPrompt || '',
|
||||
autoReply: config.autoReply !== false,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAvatarUpdatePayload(form) {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
displayName: form.displayName.trim() || form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
status: form.status,
|
||||
photoUrl: form.photoUrl.trim(),
|
||||
config: {
|
||||
replyStyle: form.replyStyle,
|
||||
creativity: Number(form.creativity),
|
||||
rigor: Number(form.rigor),
|
||||
humor: Number(form.humor),
|
||||
responseLength: form.responseLength,
|
||||
systemPrompt: form.systemPrompt.trim(),
|
||||
autoReply: !!form.autoReply,
|
||||
},
|
||||
}
|
||||
}
|
||||
65
digital-avatar-app/src/utils/uniapp-bridge.ts
Normal file
65
digital-avatar-app/src/utils/uniapp-bridge.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
|
||||
// 协议详见 uniapp-avatar/README.md
|
||||
//
|
||||
// 引入方式:在 index.html 中加载 uniapp web-view bridge:
|
||||
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
||||
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
|
||||
|
||||
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
||||
|
||||
export interface UniLaunchParams {
|
||||
token?: string
|
||||
userId?: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
ts?: string
|
||||
}
|
||||
|
||||
// 是否运行在 uniapp web-view 环境中
|
||||
export function isInUniWebView(): boolean {
|
||||
return !!(window as any).uni?.webView
|
||||
}
|
||||
|
||||
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||
export function getLaunchParams(): UniLaunchParams {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const params: UniLaunchParams = {}
|
||||
const token = sp.get('token')
|
||||
const userId = sp.get('userId')
|
||||
const nickname = sp.get('nickname')
|
||||
const avatar = sp.get('avatar')
|
||||
const ts = sp.get('ts')
|
||||
if (token) params.token = token
|
||||
if (userId) params.userId = userId
|
||||
if (nickname) params.nickname = decodeURIComponent(nickname)
|
||||
if (avatar) params.avatar = decodeURIComponent(avatar)
|
||||
if (ts) params.ts = ts
|
||||
return params
|
||||
}
|
||||
|
||||
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
|
||||
export function postToNative(message: Record<string, any>): boolean {
|
||||
if (!isInUniWebView()) return false
|
||||
;(window as any).uni.webView.postMessage({ data: message })
|
||||
return true
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
|
||||
export function onNativeMessage(handler: (message: any) => void): void {
|
||||
;(window as any)[BRIDGE_HANDLER] = (message: any) => {
|
||||
try {
|
||||
handler(message)
|
||||
} catch (e) {
|
||||
console.error('[uniBridge] handler error', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷事件
|
||||
export const UniEvents = {
|
||||
ready: () => postToNative({ type: 'ready' }),
|
||||
needLogin: () => postToNative({ type: 'needLogin' }),
|
||||
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
||||
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
||||
back: () => postToNative({ type: 'back' })
|
||||
}
|
||||
324
digital-avatar-app/src/views/AuthorizationManage.vue
Normal file
324
digital-avatar-app/src/views/AuthorizationManage.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="auth-manage-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">授权管理</h1>
|
||||
<button class="add-btn" @click="addAuthorization">+</button>
|
||||
</header>
|
||||
|
||||
<!-- 授权列表 -->
|
||||
<section class="auth-list" v-if="authList.length > 0">
|
||||
<div class="auth-card" v-for="auth in authList" :key="auth.id">
|
||||
<div class="auth-icon" :class="auth.targetType">
|
||||
{{ getAuthIcon(auth.targetType) }}
|
||||
</div>
|
||||
<div class="auth-info">
|
||||
<h3 class="auth-name">{{ auth.targetName }}</h3>
|
||||
<p class="auth-type">{{ getAuthTypeText(auth.targetType) }}</p>
|
||||
<div class="auth-permissions">
|
||||
<span class="permission-tag" v-for="perm in auth.permissions" :key="perm">
|
||||
{{ getPermissionText(perm) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-actions">
|
||||
<span class="auth-status" :class="auth.status">
|
||||
{{ auth.status === 'active' ? '已授权' : '已撤销' }}
|
||||
</span>
|
||||
<button class="auth-toggle-btn" @click="toggleAuth(auth)">
|
||||
{{ auth.status === 'active' ? '撤销' : '授权' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<section class="empty-state" v-else>
|
||||
<div class="empty-icon">🔐</div>
|
||||
<h3 class="empty-title">暂无授权</h3>
|
||||
<p class="empty-desc">授权其他用户或应用访问你的数字分身</p>
|
||||
<button class="empty-btn" @click="addAuthorization">添加授权</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { getAuthorizationList, updateAuthorization } from '@/api'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
|
||||
// 授权列表
|
||||
const authList = ref<Array<{
|
||||
id: string
|
||||
targetType: 'user' | 'organization' | 'application'
|
||||
targetName: string
|
||||
permissions: string[]
|
||||
status: 'active' | 'inactive'
|
||||
}>>([])
|
||||
|
||||
// 从后端加载授权列表
|
||||
const loadAuth = async () => {
|
||||
try {
|
||||
if (!avatarStore.avatars.length) {
|
||||
await avatarStore.loadAvatars()
|
||||
}
|
||||
const avatarId = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars)
|
||||
if (!avatarId) {
|
||||
authList.value = []
|
||||
return
|
||||
}
|
||||
const res: any = await getAuthorizationList(avatarId)
|
||||
authList.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
console.error('加载授权失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取授权图标
|
||||
const getAuthIcon = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'user': '👤',
|
||||
'organization': '🏢',
|
||||
'application': '📱'
|
||||
}
|
||||
return map[type] || '🔑'
|
||||
}
|
||||
|
||||
// 获取授权类型文本
|
||||
const getAuthTypeText = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'user': '用户',
|
||||
'organization': '组织',
|
||||
'application': '应用'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 获取权限文本
|
||||
const getPermissionText = (perm: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'read': '读取',
|
||||
'write': '写入',
|
||||
'reply': '回复',
|
||||
'edit': '编辑'
|
||||
}
|
||||
return map[perm] || perm
|
||||
}
|
||||
|
||||
// 切换授权状态(写入后端)
|
||||
const toggleAuth = async (auth: any) => {
|
||||
const newStatus = auth.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
const avatarId = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars)
|
||||
if (!avatarId) return
|
||||
const res: any = await updateAuthorization(avatarId, {
|
||||
id: auth.id,
|
||||
status: newStatus
|
||||
})
|
||||
authList.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
alert('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 添加授权
|
||||
const addAuthorization = () => {
|
||||
alert('添加授权功能开发中...')
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-manage-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 授权列表 */
|
||||
.auth-list {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
font-size: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: #FFF0E6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.auth-type {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.auth-permissions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.permission-tag {
|
||||
padding: 4px 8px;
|
||||
background: #F3F4F6;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-status.active {
|
||||
color: #22C55E;
|
||||
}
|
||||
|
||||
.auth-status.inactive {
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.auth-toggle-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
padding: 12px 32px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
185
digital-avatar-app/src/views/AvatarCard.vue
Normal file
185
digital-avatar-app/src/views/AvatarCard.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="avatar-card-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身名片</h1>
|
||||
</div>
|
||||
<button class="share-btn" @click="shareCard">分享</button>
|
||||
</header>
|
||||
|
||||
<!-- 名片预览 -->
|
||||
<section class="card-preview">
|
||||
<div class="digital-card">
|
||||
<div class="card-top">
|
||||
<div class="card-avatar">{{ avatar.emoji }}</div>
|
||||
<span class="card-status" :class="avatar.status">● {{ statusText(avatar.status) }}</span>
|
||||
</div>
|
||||
<h2 class="card-name">{{ avatar.displayName }}</h2>
|
||||
<p class="card-desc">{{ avatar.description }}</p>
|
||||
<div class="card-tags">
|
||||
<span class="card-tag" v-for="t in avatar.tags" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
<div class="card-qr">
|
||||
<div class="qr-placeholder" aria-hidden="true">▦</div>
|
||||
<span class="qr-tip">扫码添加我的分身</span>
|
||||
</div>
|
||||
<div class="card-id">会会号:{{ avatar.huihuiId }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 名片信息 -->
|
||||
<section class="info-section">
|
||||
<h3 class="section-title">名片信息</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row"><span class="info-label">分身名称</span><span class="info-value">{{ avatar.displayName }}</span></div>
|
||||
<div class="info-row"><span class="info-label">会会号</span><span class="info-value">{{ avatar.huihuiId }}</span></div>
|
||||
<div class="info-row"><span class="info-label">状态</span><span class="info-value">{{ statusText(avatar.status) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">创建时间</span><span class="info-value">{{ formatDate(avatar.createdAt) }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作 -->
|
||||
<section class="action-section">
|
||||
<button class="primary-btn" @click="copyLink">复制分享链接</button>
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { getAvatarDetail } from '@/api'
|
||||
import { pickAvatarId } from '@/utils/avatar-page-data'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const avatarStore = useAvatarStore()
|
||||
const toast = ref('')
|
||||
const loading = ref(true)
|
||||
const selectedAvatarId = ref('')
|
||||
|
||||
const avatar = ref({
|
||||
emoji: '🤖',
|
||||
displayName: '会会助手',
|
||||
description: '我是您的AI数字分身,可以帮您管理日程、回复消息、处理任务。',
|
||||
huihuiId: 'huihui_8848',
|
||||
status: 'active',
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
tags: ['智能对话', '日程管理', '社交助手']
|
||||
})
|
||||
|
||||
const applyAvatar = (a: any) => {
|
||||
if (!a) return
|
||||
avatar.value = {
|
||||
emoji: a.emoji || '🤖',
|
||||
displayName: a.displayName || a.name || '我的分身',
|
||||
description: a.description || '暂无描述',
|
||||
huihuiId: a.huihuiId || 'huihui_' + (a.id || '0000'),
|
||||
status: a.status || 'active',
|
||||
createdAt: a.createdAt || new Date().toISOString(),
|
||||
tags: a.tags || ['智能对话', '日程管理']
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||||
const requestedId = String(route.query.id || '')
|
||||
const id = pickAvatarId(requestedId, avatarStore.avatars)
|
||||
selectedAvatarId.value = id || ''
|
||||
const localAvatar = avatarStore.avatars.find((a) => String(a.id) === id)
|
||||
if (localAvatar) {
|
||||
applyAvatar(localAvatar)
|
||||
} else if (requestedId) {
|
||||
applyAvatar(await getAvatarDetail(requestedId))
|
||||
selectedAvatarId.value = requestedId
|
||||
} else {
|
||||
applyAvatar(avatarStore.avatars[0])
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = (s: string) => ({ active: '活跃中', inactive: '未激活', training: '训练中' }[s] || '活跃中')
|
||||
const formatDate = (t: string) => new Date(t).toLocaleDateString('zh-CN')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const shareCard = () => {
|
||||
toast.value = '请复制链接分享给对方'
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
const copyLink = async () => {
|
||||
const id = selectedAvatarId.value
|
||||
const redirect = router.resolve({ path: '/avatar/card', query: id ? { id: String(id) } : undefined }).href
|
||||
const loginUrl = router.resolve({ path: '/login/sms', query: { redirect } }).href
|
||||
const hash = loginUrl.includes('#') ? loginUrl.slice(loginUrl.indexOf('#')) : `#${loginUrl}`
|
||||
const link = `${window.location.origin}${window.location.pathname}${hash}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
toast.value = '分享链接已复制'
|
||||
} catch {
|
||||
toast.value = link
|
||||
}
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-card-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.share-btn { background: rgba(255,255,255,0.2); border: none; color: white; padding: 6px 16px; border-radius: 20px; font-size: 14px; cursor: pointer; }
|
||||
|
||||
.card-preview { padding: 16px 20px; }
|
||||
.digital-card {
|
||||
background: linear-gradient(160deg, #FFF7ED 0%, #FFFFFF 60%);
|
||||
border: 1px solid #FFE4CC; border-radius: 20px; padding: 24px;
|
||||
box-shadow: 0 8px 24px rgba(249,115,22,0.12);
|
||||
}
|
||||
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.card-avatar {
|
||||
width: 64px; height: 64px; border-radius: 18px; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 34px;
|
||||
}
|
||||
.card-status { font-size: 12px; color: #22C55E; background: #ECFDF5; padding: 4px 10px; border-radius: 20px; }
|
||||
.card-name { font-size: 22px; font-weight: 700; color: #18191C; margin: 0 0 8px; }
|
||||
.card-desc { font-size: 14px; color: #6B7280; line-height: 1.6; margin: 0 0 16px; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||
.card-tag { padding: 4px 12px; background: #FFF0E6; color: #F97316; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||
.card-qr {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 16px; background: white; border-radius: 14px; border: 1px dashed #FED7AA; margin-bottom: 14px;
|
||||
}
|
||||
.qr-placeholder { font-size: 56px; line-height: 1; color: #F97316; letter-spacing: 4px; }
|
||||
.qr-tip { font-size: 12px; color: #9398AE; }
|
||||
.card-id { text-align: center; font-size: 13px; color: #9398AE; }
|
||||
|
||||
.info-section { padding: 0 20px 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; margin: 0 0 12px; color: #18191C; }
|
||||
.info-list { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); overflow: hidden; }
|
||||
.info-row { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #F3F4F6; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-size: 14px; color: #6B7280; }
|
||||
.info-value { font-size: 14px; color: #18191C; font-weight: 500; }
|
||||
|
||||
.action-section { padding: 0 20px; }
|
||||
.primary-btn {
|
||||
width: 100%; padding: 14px; background: #F97316; color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: opacity 0.2s;
|
||||
}
|
||||
.primary-btn:hover { opacity: 0.9; }
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 12px 0 0; word-break: break-all; }
|
||||
</style>
|
||||
154
digital-avatar-app/src/views/AvatarChat.vue
Normal file
154
digital-avatar-app/src/views/AvatarChat.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="chat-page">
|
||||
<header class="chat-header">
|
||||
<button class="back-btn" @click="router.back()">‹</button>
|
||||
<div class="avatar-heading">
|
||||
<div class="avatar-mark">{{ avatar?.emoji || '🤖' }}</div>
|
||||
<div>
|
||||
<h1>{{ avatar?.displayName || avatar?.name || '数字分身' }}</h1>
|
||||
<span class="online-state">● 随时可以和我聊聊</span>
|
||||
</div>
|
||||
</div>
|
||||
<button 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>
|
||||
</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 class="message-column">
|
||||
<div class="message-bubble">{{ message.content }}</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>
|
||||
</article>
|
||||
|
||||
<div v-if="sending" class="message-row assistant">
|
||||
<div class="message-avatar">{{ avatar?.emoji || '🤖' }}</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>
|
||||
</main>
|
||||
|
||||
<form class="composer" @submit.prevent="sendMessage(inputText)">
|
||||
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入你想聊的内容…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
|
||||
<button class="send-btn" type="submit" :disabled="sending || !inputText.trim()">发送</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getAvatarDetail, sendAvatarChat, type ChatMessage } from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
|
||||
type DisplayMessage = ChatMessage & {
|
||||
source?: 'qa' | 'knowledge' | 'qwen'
|
||||
references?: Array<{ filename?: string }>
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAvatarStore()
|
||||
const avatarId = String(route.params.id || '')
|
||||
const avatar = ref<any>(null)
|
||||
const messages = ref<DisplayMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastQuestion = ref('')
|
||||
const messageList = ref<HTMLElement | null>(null)
|
||||
const starters = ['介绍一下你自己', '你能帮我做什么?', '请根据我的知识库回答一个问题']
|
||||
|
||||
const sourceLabel = (source?: DisplayMessage['source']) => ({
|
||||
qa: '标准问答对',
|
||||
knowledge: '参考文件知识库',
|
||||
qwen: 'Qwen 智能回答'
|
||||
}[source || ''] || '')
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||
}
|
||||
|
||||
const loadAvatar = async () => {
|
||||
avatar.value = store.avatars.find((item) => String(item.id) === avatarId)
|
||||
if (!avatar.value) avatar.value = await getAvatarDetail(avatarId)
|
||||
}
|
||||
|
||||
const sendMessage = async (value: string) => {
|
||||
const question = value.trim()
|
||||
if (!question || sending.value) return
|
||||
lastQuestion.value = question
|
||||
inputText.value = ''
|
||||
errorMessage.value = ''
|
||||
messages.value.push({ role: 'user', content: question })
|
||||
sending.value = true
|
||||
await scrollToBottom()
|
||||
try {
|
||||
const response = await sendAvatarChat(avatarId, {
|
||||
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()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error?.message || '暂时无法回答,请稍后重试'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const retryLast = () => {
|
||||
if (!lastQuestion.value || sending.value) return
|
||||
const last = messages.value[messages.value.length - 1]
|
||||
if (last?.role === 'user') messages.value.pop()
|
||||
sendMessage(lastQuestion.value)
|
||||
}
|
||||
|
||||
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); }
|
||||
.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-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; }
|
||||
.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; }
|
||||
.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-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-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; } }
|
||||
.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; }
|
||||
.send-btn { align-self: flex-end; padding: 11px 18px; border: 0; border-radius: 12px; color: white; background: #F97316; cursor: pointer; }.send-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
</style>
|
||||
142
digital-avatar-app/src/views/AvatarContacts.vue
Normal file
142
digital-avatar-app/src/views/AvatarContacts.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="contacts-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身人脉</h1>
|
||||
</div>
|
||||
<button class="add-btn" @click="addContact">+</button>
|
||||
</header>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<section class="search-section">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input v-model="keyword" class="search-input" placeholder="搜索人脉" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 人脉列表 -->
|
||||
<section class="contacts-section">
|
||||
<div class="contact-list" v-if="filteredContacts.length > 0">
|
||||
<div class="contact-item" v-for="c in filteredContacts" :key="c.id">
|
||||
<div class="contact-avatar">{{ c.emoji }}</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name-row">
|
||||
<span class="contact-name">{{ c.name }}</span>
|
||||
<span class="relation-tag" :class="c.relation">{{ relationText(c.relation) }}</span>
|
||||
</div>
|
||||
<span class="contact-last">最近互动:{{ c.last }}</span>
|
||||
</div>
|
||||
<button class="chat-btn" @click="openChat(c)">💬</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">🤝</span>
|
||||
<p class="empty-text">还没有人脉,点击右上角添加</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const toast = ref('')
|
||||
|
||||
interface Contact { id: string; emoji: string; name: string; relation: string; last: string }
|
||||
const contacts = ref<Contact[]>([
|
||||
{ id: '1', emoji: '👩', name: '林小满', relation: 'friend', last: '今天 14:20' },
|
||||
{ id: '2', emoji: '🧑', name: '陈工', relation: 'colleague', last: '昨天 09:10' },
|
||||
{ id: '3', emoji: '👨', name: '王总', relation: 'client', last: '3 天前' },
|
||||
{ id: '4', emoji: '🧑', name: 'Anna', relation: 'friend', last: '上周' }
|
||||
])
|
||||
|
||||
const filteredContacts = computed(() =>
|
||||
contacts.value.filter(c => c.name.includes(keyword.value))
|
||||
)
|
||||
|
||||
const relationText = (r: string) => ({
|
||||
friend: '好友', colleague: '同事', client: '客户'
|
||||
}[r] || '好友')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const addContact = () => {
|
||||
const sample = ['👤', '🧑', '👩', '🧑']
|
||||
const names = ['新朋友', '合作伙伴', '张同学', '李经理']
|
||||
const relations = ['friend', 'colleague', 'client']
|
||||
const idx = contacts.value.length
|
||||
contacts.value.unshift({
|
||||
id: String(Date.now()),
|
||||
emoji: sample[idx % sample.length],
|
||||
name: names[idx % names.length],
|
||||
relation: relations[idx % relations.length],
|
||||
last: '刚刚添加'
|
||||
})
|
||||
toast.value = '已添加示例人脉'
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
const openChat = (c: Contact) => {
|
||||
toast.value = `打开与 ${c.name} 的对话(示例)`
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contacts-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.add-btn { background: rgba(255,255,255,0.2); border: none; color: white; font-size: 22px; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; }
|
||||
|
||||
.search-section { padding: 16px 20px; }
|
||||
.search-box { display: flex; align-items: center; gap: 8px; background: white; border-radius: 12px; padding: 10px 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.search-icon { font-size: 16px; }
|
||||
.search-input { flex: 1; border: none; outline: none; font-size: 14px; color: #18191C; background: transparent; }
|
||||
|
||||
.contacts-section { padding: 0 20px; }
|
||||
.contact-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.contact-item {
|
||||
display: flex; align-items: center; gap: 12px; padding: 14px 16px;
|
||||
background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.contact-avatar {
|
||||
width: 44px; height: 44px; border-radius: 50%; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.contact-info { flex: 1; min-width: 0; }
|
||||
.contact-name-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.contact-name { font-size: 15px; font-weight: 600; color: #18191C; }
|
||||
.relation-tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.relation-tag.friend { background: #ECFDF5; color: #22C55E; }
|
||||
.relation-tag.colleague { background: #EFF6FF; color: #3B82F6; }
|
||||
.relation-tag.client { background: #FFF0E6; color: #F97316; }
|
||||
.contact-last { font-size: 12px; color: #9398AE; }
|
||||
.chat-btn {
|
||||
width: 38px; height: 38px; border-radius: 50%; border: none; background: #FFF0E6;
|
||||
font-size: 18px; cursor: pointer; flex-shrink: 0; transition: transform 0.2s;
|
||||
}
|
||||
.chat-btn:hover { transform: scale(1.08); }
|
||||
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 40px 20px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9398AE; margin: 0; }
|
||||
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 16px 20px 0; }
|
||||
</style>
|
||||
492
digital-avatar-app/src/views/AvatarCreate.vue
Normal file
492
digital-avatar-app/src/views/AvatarCreate.vue
Normal file
@@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<div class="create-page">
|
||||
<!-- 步骤 0:引导落地页 -->
|
||||
<div v-if="step === 0" class="landing">
|
||||
<header class="hero">
|
||||
<div class="hero-icon">🤖</div>
|
||||
<h1 class="hero-title">创建你的数字分身</h1>
|
||||
<p class="hero-sub">AI 助手,为你工作、社交、创造价值</p>
|
||||
</header>
|
||||
|
||||
<section class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💬</div>
|
||||
<h3>智能对话</h3>
|
||||
<p>24/7 在线,自动回复消息、处理咨询</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📅</div>
|
||||
<h3>日程管理</h3>
|
||||
<p>智能安排会议、提醒重要事项</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🤝</div>
|
||||
<h3>社交助手</h3>
|
||||
<p>管理人脉、扩展社交圈</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<button class="create-btn" @click="step = 1">
|
||||
<span class="btn-icon">✨</span> 开始创建
|
||||
</button>
|
||||
<p class="hint">只需 4 步,快速拥有你的 AI 分身</p>
|
||||
</section>
|
||||
|
||||
<section class="existing" v-if="avatarStore.avatars.length > 0">
|
||||
<p>已有数字分身?</p>
|
||||
<button class="link-btn" @click="goManage">前往管理 →</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 1-4:创建向导 -->
|
||||
<div v-else class="wizard">
|
||||
<header class="wizard-header">
|
||||
<button class="back" @click="prev">‹</button>
|
||||
<h2 class="w-title">{{ stepTitles[step - 1] }}</h2>
|
||||
<span class="step-count">{{ step }}/4</span>
|
||||
</header>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: (step / 4 * 100) + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
<!-- Step 1:基础信息 -->
|
||||
<div v-if="step === 1" class="form">
|
||||
<div class="field">
|
||||
<label class="field-label">分身头像</label>
|
||||
<div class="avatar-pick">
|
||||
<div class="avatar-pick-preview">
|
||||
<img v-if="form.photoUrl" :src="form.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ form.emoji }}</span>
|
||||
</div>
|
||||
<p class="avatar-pick-hint">已沿用你的会会头像,也可在下一步选择表情形象</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">分身名称 <span class="req">*</span></label>
|
||||
<input v-model="form.name" class="input" placeholder="如:我的数字分身" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">显示名称</label>
|
||||
<input v-model="form.displayName" class="input" placeholder="如:会会助手" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">分身描述</label>
|
||||
<textarea v-model="form.description" class="textarea" rows="3" placeholder="描述它的功能与特点"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2:形象风格 -->
|
||||
<div v-else-if="step === 2" class="form">
|
||||
<p class="field-label">选择形象</p>
|
||||
|
||||
<!-- 默认:沿用当前用户的会会头像 -->
|
||||
<div
|
||||
v-if="userAvatarUrl"
|
||||
class="avatar-photo-option"
|
||||
:class="{ active: usePhoto }"
|
||||
@click="selectMyAvatar"
|
||||
>
|
||||
<div class="apo-thumb">
|
||||
<img :src="userAvatarUrl" alt="" referrerpolicy="no-referrer" />
|
||||
</div>
|
||||
<div class="apo-meta">
|
||||
<span class="apo-title">使用我的头像</span>
|
||||
<span class="apo-sub">沿用你的会会账号头像</span>
|
||||
</div>
|
||||
<span class="apo-check">✓</span>
|
||||
</div>
|
||||
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="e in emojis"
|
||||
:key="e"
|
||||
class="emoji-opt"
|
||||
:class="{ active: !usePhoto && form.emoji === e }"
|
||||
@click="selectEmoji(e)"
|
||||
>{{ e }}</button>
|
||||
</div>
|
||||
<p class="field-label">回复风格</p>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="s in styles"
|
||||
:key="s.value"
|
||||
class="seg-btn"
|
||||
:class="{ active: form.replyStyle === s.value }"
|
||||
@click="form.replyStyle = s.value"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3:灵魂配置 -->
|
||||
<div v-else-if="step === 3" class="form">
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>创造力</span><b>{{ form.creativity }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.creativity" class="range" />
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>严谨度</span><b>{{ form.rigor }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.rigor" class="range" />
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>幽默感</span><b>{{ form.humor }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.humor" class="range" />
|
||||
</div>
|
||||
<p class="field-label">回复长度</p>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="l in lengths"
|
||||
:key="l.value"
|
||||
class="seg-btn"
|
||||
:class="{ active: form.responseLength === l.value }"
|
||||
@click="form.responseLength = l.value"
|
||||
>{{ l.label }}</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">系统提示词(选填)</label>
|
||||
<textarea v-model="form.systemPrompt" class="textarea" rows="3" placeholder="给分身的额外指令,如:语气要专业、回答要简洁"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4:确认创建 -->
|
||||
<div v-else class="confirm">
|
||||
<div class="confirm-avatar">
|
||||
<img v-if="usePhoto && form.photoUrl" :src="form.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ form.emoji }}</span>
|
||||
</div>
|
||||
<h3 class="confirm-name">{{ form.displayName || form.name || '未命名分身' }}</h3>
|
||||
<p class="confirm-desc">{{ form.description || '暂无描述' }}</p>
|
||||
<ul class="confirm-list">
|
||||
<li><span>回复风格</span><b>{{ styleLabel }}</b></li>
|
||||
<li><span>创造力 / 严谨度 / 幽默感</span><b>{{ form.creativity }} / {{ form.rigor }} / {{ form.humor }}</b></li>
|
||||
<li><span>回复长度</span><b>{{ lengthLabel }}</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="wizard-footer">
|
||||
<button v-if="step > 1" class="btn-secondary" @click="prev">上一步</button>
|
||||
<button v-if="step < 4" class="btn-primary" :disabled="!canNext" @click="next">下一步</button>
|
||||
<button v-else class="btn-primary" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? '创建中...' : '创建分身' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { getCurrentUser } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const step = ref(0)
|
||||
const stepTitles = ['基础信息', '形象风格', '灵魂配置', '确认创建']
|
||||
const submitting = ref(false)
|
||||
|
||||
const emojis = ['🤖', '😊', '🦊', '🐱', '🦁', '🐼', '👩💻', '🧑🚀', '💡', '🌟']
|
||||
const styles = [
|
||||
{ value: 'professional', label: '专业' },
|
||||
{ value: 'casual', label: '轻松' },
|
||||
{ value: 'friendly', label: '亲切' }
|
||||
]
|
||||
const lengths = [
|
||||
{ value: 'short', label: '简短' },
|
||||
{ value: 'medium', label: '适中' },
|
||||
{ value: 'long', label: '详细' }
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
displayName: '',
|
||||
photoUrl: '',
|
||||
description: '',
|
||||
emoji: '🤖',
|
||||
replyStyle: 'professional',
|
||||
creativity: 50,
|
||||
rigor: 50,
|
||||
humor: 30,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: ''
|
||||
})
|
||||
|
||||
const styleLabel = computed(() => styles.find(s => s.value === form.replyStyle)?.label || '')
|
||||
const lengthLabel = computed(() => lengths.find(l => l.value === form.responseLength)?.label || '')
|
||||
|
||||
// 当前登录用户的会会头像(用于「形象选择」默认项)
|
||||
// 优先采用真实登录态 userStore.user,避免被空的 userProfile 覆盖
|
||||
const userAvatarUrl = computed(() => {
|
||||
const u = userStore.user
|
||||
const p = avatarStore.userProfile
|
||||
return ((u?.avatarUrl as string) || (p?.avatarUrl as string) || '')
|
||||
})
|
||||
|
||||
// 形象选择:默认沿用当前用户的会会头像;选中表情后切换为 emoji 模式
|
||||
const usePhoto = ref(false)
|
||||
const selectMyAvatar = () => {
|
||||
usePhoto.value = true
|
||||
form.photoUrl = userAvatarUrl.value
|
||||
}
|
||||
const selectEmoji = (e: string) => {
|
||||
form.emoji = e
|
||||
usePhoto.value = false
|
||||
form.photoUrl = ''
|
||||
}
|
||||
|
||||
const canNext = computed(() => {
|
||||
if (step.value === 1) return form.name.trim().length > 0
|
||||
return true
|
||||
})
|
||||
|
||||
const next = () => { if (step.value < 4) step.value++ }
|
||||
const prev = () => { if (step.value === 1) step.value = 0; else step.value-- }
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await avatarStore.addAvatar({
|
||||
name: form.name,
|
||||
displayName: form.displayName || form.name,
|
||||
photoUrl: usePhoto.value ? form.photoUrl : '',
|
||||
description: form.description,
|
||||
emoji: form.emoji,
|
||||
config: {
|
||||
replyStyle: form.replyStyle,
|
||||
creativity: form.creativity,
|
||||
rigor: form.rigor,
|
||||
humor: form.humor,
|
||||
responseLength: form.responseLength,
|
||||
systemPrompt: form.systemPrompt
|
||||
}
|
||||
})
|
||||
router.push('/avatar/manage')
|
||||
} catch (e) {
|
||||
alert('创建失败,请稍后重试')
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 预填会会登录用户的名称与头像(沿用当前账号资料)
|
||||
// 优先采用真实登录态 userStore.user(持久化会话),避免被空资料 / mock 覆盖
|
||||
onMounted(async () => {
|
||||
userStore.loadFromStorage()
|
||||
const real = userStore.user
|
||||
const native = avatarStore.userProfile
|
||||
let nick = real?.nickname || native?.nickname || ''
|
||||
let avatar = real?.avatarUrl || native?.avatarUrl || ''
|
||||
// 兜底:本地资料为空时,向服务端 /huihui/me 拉取真实登录态(Bearer app_token)
|
||||
if ((!nick || !avatar) && userStore.isLogin) {
|
||||
try {
|
||||
const me2: any = await getCurrentUser()
|
||||
nick = nick || me2?.nickname || ''
|
||||
avatar = avatar || me2?.avatarUrl || ''
|
||||
} catch { /* 忽略,保持本地值 */ }
|
||||
}
|
||||
if (nick) {
|
||||
if (!form.displayName) form.displayName = nick
|
||||
if (!form.name) form.name = nick
|
||||
}
|
||||
if (avatar && !form.photoUrl) form.photoUrl = avatar
|
||||
// 「形象选择」默认使用当前用户的会会头像
|
||||
if (userAvatarUrl.value) usePhoto.value = true
|
||||
})
|
||||
|
||||
const goManage = () => router.push('/avatar/manage')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
}
|
||||
|
||||
/* ===== 落地页 ===== */
|
||||
.landing { padding-bottom: 40px; }
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 50%, #FDBA74 100%);
|
||||
padding: 60px 20px 80px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100% { transform: scale(1);} 50% { transform: scale(1.1);} }
|
||||
.hero-title { font-size: 28px; font-weight: 700; margin: 0 0 12px; letter-spacing: 1px; }
|
||||
.hero-sub { font-size: 16px; opacity: 0.9; margin: 0; }
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
margin-top: -40px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.feature-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.feature-icon { font-size: 36px; margin-bottom: 12px; }
|
||||
.feature-card h3 { font-size: 15px; font-weight: 600; color: #18191C; margin: 0 0 8px; }
|
||||
.feature-card p { font-size: 12px; color: #9398AE; margin: 0; line-height: 1.4; }
|
||||
|
||||
.cta { padding: 30px 20px; text-align: center; }
|
||||
.create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 48px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(249,115,22,0.4);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.create-btn:active { transform: scale(0.97); }
|
||||
.hint { font-size: 13px; color: #9398AE; margin-top: 16px; }
|
||||
|
||||
.existing { text-align: center; padding: 10px; }
|
||||
.existing p { font-size: 14px; color: #9398AE; margin: 0 0 12px; }
|
||||
.link-btn {
|
||||
background: none; border: 2px solid #F97316; color: #F97316;
|
||||
padding: 10px 24px; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.link-btn:hover { background: #F97316; color: white; }
|
||||
|
||||
/* ===== 向导 ===== */
|
||||
.wizard { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.wizard-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: white; border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
.back { background: none; border: none; font-size: 26px; cursor: pointer; color: #18191C; padding: 0 8px; }
|
||||
.w-title { font-size: 17px; font-weight: 600; margin: 0; color: #18191C; }
|
||||
.step-count { font-size: 13px; color: #9398AE; }
|
||||
|
||||
.progress { height: 4px; background: #EDEEF1; }
|
||||
.progress-bar { height: 100%; background: linear-gradient(90deg, #F97316, #FB923C); transition: width 0.35s ease; }
|
||||
|
||||
.wizard-body { flex: 1; padding: 24px 20px; }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field-label { font-size: 14px; font-weight: 500; color: #18191C; }
|
||||
.req { color: #EF4444; }
|
||||
|
||||
.avatar-pick { display: flex; flex-direction: column; gap: 10px; }
|
||||
.avatar-pick-preview {
|
||||
width: 72px; height: 72px; border-radius: 50%; overflow: hidden;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 36px; box-shadow: 0 4px 12px rgba(249,115,22,0.3);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
.avatar-pick-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar-pick-hint { font-size: 12px; color: #9398AE; margin: 0; }
|
||||
.input, .textarea {
|
||||
width: 100%; padding: 12px 16px; border: 1px solid #EDEEF1; border-radius: 10px;
|
||||
font-size: 15px; color: #18191C; background: white; font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input:focus, .textarea:focus { outline: none; border-color: #F97316; }
|
||||
.textarea { resize: vertical; }
|
||||
|
||||
.emoji-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
|
||||
.emoji-opt {
|
||||
aspect-ratio: 1; font-size: 28px; border: 2px solid #EDEEF1; border-radius: 12px;
|
||||
background: white; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.emoji-opt.active { border-color: #F97316; background: #FFF0E6; }
|
||||
|
||||
/* 形象选择:默认沿用会会头像 */
|
||||
.avatar-photo-option {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 16px; border: 2px solid #EDEEF1; border-radius: 14px;
|
||||
background: white; cursor: pointer; transition: all 0.2s; margin-bottom: 18px;
|
||||
}
|
||||
.avatar-photo-option.active { border-color: #F97316; background: #FFF0E6; }
|
||||
.apo-thumb {
|
||||
width: 52px; height: 52px; border-radius: 50%; overflow: hidden; flex: 0 0 auto;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
border: 2px solid #fff; box-shadow: 0 2px 8px rgba(249,115,22,0.3);
|
||||
}
|
||||
.apo-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.apo-meta { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.apo-title { font-size: 15px; font-weight: 600; color: #18191C; }
|
||||
.apo-sub { font-size: 12px; color: #9398AE; }
|
||||
.apo-check { font-size: 18px; color: #F97316; opacity: 0; transition: opacity 0.2s; flex: 0 0 auto; }
|
||||
.avatar-photo-option.active .apo-check { opacity: 1; }
|
||||
|
||||
.seg { display: flex; gap: 12px; }
|
||||
.seg-btn {
|
||||
flex: 1; padding: 12px; border: 2px solid #EDEEF1; border-radius: 10px; background: white;
|
||||
font-size: 14px; color: #9398AE; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.seg-btn.active { border-color: #F97316; color: #F97316; background: #FFF0E6; }
|
||||
|
||||
.slider-item { display: flex; flex-direction: column; gap: 10px; }
|
||||
.slider-head { display: flex; justify-content: space-between; font-size: 14px; color: #18191C; }
|
||||
.slider-head b { color: #F97316; }
|
||||
.range { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 3px; background: #EDEEF1; outline: none; }
|
||||
.range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #F97316; cursor: pointer; box-shadow: 0 2px 6px rgba(249,115,22,0.4);
|
||||
}
|
||||
|
||||
.confirm { display: flex; flex-direction: column; align-items: center; gap: 12px; padding-top: 10px; }
|
||||
.confirm-avatar {
|
||||
width: 88px; height: 88px; border-radius: 50%; overflow: hidden;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 44px;
|
||||
box-shadow: 0 4px 12px rgba(249,115,22,0.3);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
.confirm-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.confirm-name { font-size: 20px; font-weight: 700; color: #18191C; margin: 0; }
|
||||
.confirm-desc { font-size: 14px; color: #9398AE; margin: 0; text-align: center; }
|
||||
.confirm-list { width: 100%; list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.confirm-list li {
|
||||
display: flex; justify-content: space-between; padding: 14px 16px; background: white;
|
||||
border-radius: 10px; font-size: 13px; color: #9398AE; box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.confirm-list li b { color: #18191C; font-weight: 600; }
|
||||
|
||||
.wizard-footer {
|
||||
display: flex; gap: 12px; padding: 16px 20px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: white; border-top: 1px solid #EDEEF1;
|
||||
}
|
||||
.btn-secondary {
|
||||
flex: 0 0 auto; padding: 14px 24px; background: white; color: #18191C;
|
||||
border: 2px solid #EDEEF1; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
flex: 1; padding: 14px; background: linear-gradient(135deg, #F97316, #FB923C); color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(249,115,22,0.3); transition: opacity 0.2s;
|
||||
}
|
||||
.btn-primary:disabled { background: #D1D5DB; box-shadow: none; cursor: not-allowed; }
|
||||
</style>
|
||||
533
digital-avatar-app/src/views/AvatarEdit.vue
Normal file
533
digital-avatar-app/src/views/AvatarEdit.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<div class="edit-avatar-page">
|
||||
<!-- 顶部导航 -->
|
||||
<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">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="status-banner">加载中...</div>
|
||||
<div v-else-if="errorMsg" class="status-banner error">{{ errorMsg }}</div>
|
||||
|
||||
<!-- 头像预览 -->
|
||||
<section class="photo-section">
|
||||
<div class="photo-container">
|
||||
<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>
|
||||
</div>
|
||||
<span class="photo-hint">可直接修改下方头像链接</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 基本信息表单 -->
|
||||
<section class="form-section">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">分身名称</label>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
class="form-input"
|
||||
placeholder="请输入分身名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">显示名称</label>
|
||||
<input
|
||||
v-model="formData.displayName"
|
||||
class="form-input"
|
||||
placeholder="请输入显示名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">分身描述</label>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
class="form-textarea"
|
||||
placeholder="描述你的数字分身的功能和特点"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">头像链接</label>
|
||||
<input
|
||||
v-model="formData.photoUrl"
|
||||
class="form-input"
|
||||
placeholder="请输入头像图片 URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">状态</label>
|
||||
<div class="status-selector">
|
||||
<button
|
||||
class="status-option"
|
||||
:class="{ active: formData.status === 'active' }"
|
||||
@click="formData.status = 'active'"
|
||||
>
|
||||
活跃中
|
||||
</button>
|
||||
<button
|
||||
class="status-option"
|
||||
:class="{ active: formData.status === 'inactive' }"
|
||||
@click="formData.status = 'inactive'"
|
||||
>
|
||||
未激活
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 高级设置 -->
|
||||
<section class="form-section">
|
||||
<h3 class="section-title">高级设置</h3>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">回复风格</label>
|
||||
<select v-model="formData.replyStyle" class="form-select">
|
||||
<option value="professional">专业正式</option>
|
||||
<option value="casual">轻松随意</option>
|
||||
<option value="friendly">友好亲切</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<div class="slider-head"><label class="form-label">创造力</label><b>{{ formData.creativity }}</b></div>
|
||||
<input v-model.number="formData.creativity" type="range" min="0" max="100" class="range" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<div class="slider-head"><label class="form-label">严谨度</label><b>{{ formData.rigor }}</b></div>
|
||||
<input v-model.number="formData.rigor" type="range" min="0" max="100" class="range" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<div class="slider-head"><label class="form-label">幽默感</label><b>{{ formData.humor }}</b></div>
|
||||
<input v-model.number="formData.humor" type="range" min="0" max="100" class="range" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">回复长度</label>
|
||||
<div class="choice-row">
|
||||
<button v-for="item in responseLengths" :key="item.value" type="button" class="choice-btn" :class="{ active: formData.responseLength === item.value }" @click="formData.responseLength = item.value">{{ item.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">系统提示词</label>
|
||||
<textarea v-model="formData.systemPrompt" class="form-textarea" rows="4" placeholder="给分身的额外指令,例如语气、禁用内容或回答边界"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">自动回复</label>
|
||||
<div class="toggle-container">
|
||||
<span class="toggle-label">{{ formData.autoReply ? '开启' : '关闭' }}</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: formData.autoReply }"
|
||||
@click="formData.autoReply = !formData.autoReply"
|
||||
>
|
||||
<span class="toggle-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 危险操作 -->
|
||||
<section class="danger-section">
|
||||
<button class="delete-btn" :disabled="loading || deleting" @click="deleteAvatar">
|
||||
{{ deleting ? '删除中...' : '删除数字分身' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { useAvatarStore } from '@/store/avatar'
|
||||
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const avatarStore = useAvatarStore()
|
||||
const avatarId = route.params.id as string
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
photoUrl: '',
|
||||
replyStyle: 'professional',
|
||||
creativity: 50,
|
||||
rigor: 50,
|
||||
humor: 30,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: '',
|
||||
autoReply: true
|
||||
})
|
||||
|
||||
const responseLengths = [
|
||||
{ value: 'short', label: '简短' },
|
||||
{ value: 'medium', label: '适中' },
|
||||
{ value: 'long', label: '详细' }
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const loadAvatar = async () => {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const avatar: any = await getAvatarDetail(avatarId)
|
||||
Object.assign(formData, normalizeAvatarEditForm(avatar))
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '分身加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存修改
|
||||
const saveChanges = async () => {
|
||||
if (loading.value || saving.value) return
|
||||
if (!formData.name.trim()) {
|
||||
errorMsg.value = '请输入分身名称'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await updateAvatar(avatarId, buildAvatarUpdatePayload(formData))
|
||||
await avatarStore.loadAvatars()
|
||||
router.replace('/avatar/manage')
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '保存失败,请重试'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分身
|
||||
const deleteAvatar = async () => {
|
||||
if (loading.value || deleting.value) return
|
||||
if (confirm('确定要删除这个数字分身吗?此操作不可恢复。')) {
|
||||
deleting.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await apiDeleteAvatar(avatarId)
|
||||
await avatarStore.loadAvatars()
|
||||
router.replace('/avatar/manage')
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '删除失败,请重试'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAvatar()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-avatar-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
margin: 16px 20px 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #FFF7ED;
|
||||
color: #9A3412;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-banner.error {
|
||||
background: #FEF2F2;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 头像上传 */
|
||||
.photo-section {
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.photo-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-placeholder {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.photo-hint {
|
||||
font-size: 13px;
|
||||
color: #F97316;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表单区域 */
|
||||
.form-section {
|
||||
padding: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #18191C;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #EDEEF1;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: #18191C;
|
||||
background: white;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #EDEEF1;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: #18191C;
|
||||
background: white;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #EDEEF1;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: #18191C;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.slider-head .form-label { margin-bottom: 0; }
|
||||
.slider-head b { color: #F97316; font-size: 14px; }
|
||||
.range { width: 100%; accent-color: #F97316; }
|
||||
|
||||
.choice-row { display: flex; gap: 8px; }
|
||||
.choice-btn {
|
||||
flex: 1; padding: 10px 8px; border: 1px solid #EDEEF1; border-radius: 10px;
|
||||
background: white; color: #6B7280; cursor: pointer;
|
||||
}
|
||||
.choice-btn.active { border-color: #F97316; color: #F97316; background: #FFF0E6; }
|
||||
|
||||
/* 状态选择器 */
|
||||
.status-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-option {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 2px solid #EDEEF1;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
color: #9398AE;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-option.active {
|
||||
border-color: #F97316;
|
||||
color: #F97316;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
/* 开关切换 */
|
||||
.toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #EDEEF1;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 15px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
background: #D1D5DB;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.3s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: #F97316;
|
||||
}
|
||||
|
||||
.toggle-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.toggle-btn.active .toggle-dot {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* 危险操作 */
|
||||
.danger-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: white;
|
||||
color: #EF4444;
|
||||
border: 2px solid #EF4444;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #EF4444;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
840
digital-avatar-app/src/views/AvatarManage.vue
Normal file
840
digital-avatar-app/src/views/AvatarManage.vue
Normal file
@@ -0,0 +1,840 @@
|
||||
<template>
|
||||
<div class="avatar-manage-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">数字分身管理</h1>
|
||||
</div>
|
||||
<!-- 右上角创建入口 -->
|
||||
<div class="header-right">
|
||||
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
|
||||
<section class="profile-section">
|
||||
<div class="profile-avatar">
|
||||
<img v-if="me?.avatarUrl" :src="me.avatarUrl" alt="头像" referrerpolicy="no-referrer" />
|
||||
<span v-else class="profile-avatar-fallback">🤖</span>
|
||||
</div>
|
||||
<div class="profile-meta">
|
||||
<span class="profile-name">{{ me?.nickname || '会会用户' }}</span>
|
||||
<span class="profile-sub">{{ me?.phone ? '手机 ' + me.phone : '已登录 · 会会账号' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Token 余额条 -->
|
||||
<section class="token-section">
|
||||
<div class="token-card">
|
||||
<div class="token-info">
|
||||
<span class="token-label">Token 余额</span>
|
||||
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
||||
</div>
|
||||
<button class="recharge-btn" @click="goToRecharge">充值</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 数字分身列表(只放分身相关) -->
|
||||
<section class="avatar-list-section">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">我的数字分身</h3>
|
||||
<span class="count-badge">{{ avatars.length }}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<button class="chat-link" @click="goToChat(a.id)">对话</button>
|
||||
<button class="edit-link" @click="goToEdit(a.id)">编辑</button>
|
||||
<button class="del-link" @click="askDelete(a)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<span class="empty-icon">🤖</span>
|
||||
<p class="empty-text">还没有数字分身</p>
|
||||
<button class="empty-create-btn" @click="goCreate">立即创建一个</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 分身工具入口 -->
|
||||
<section class="tools-section">
|
||||
<h3 class="section-title">分身工具</h3>
|
||||
<div class="tools-grid">
|
||||
<div class="tool-card" @click="goToKnowledge">
|
||||
<div class="tool-icon">📚</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">知识库管理</span>
|
||||
<span class="tool-desc">上传文档与标准问答</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
<div class="tool-card" @click="goToAvatarCard">
|
||||
<div class="tool-icon">🪪</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">分身名片</span>
|
||||
<span class="tool-desc">生成并分享名片</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
<div class="tool-card" @click="goToAvatarContacts">
|
||||
<div class="tool-icon">🤝</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">分身人脉</span>
|
||||
<span class="tool-desc">管理社交关系</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
<div class="tool-card" @click="goToMyProjects">
|
||||
<div class="tool-icon">📁</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">我的项目</span>
|
||||
<span class="tool-desc">查看参与项目</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
<div class="tool-card" @click="goToCreateOrg">
|
||||
<div class="tool-icon">🏢</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">创建组织</span>
|
||||
<span class="tool-desc">新建组织团队</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 分身动态列表 -->
|
||||
<section class="activities-section">
|
||||
<h3 class="section-title">分身动态</h3>
|
||||
<div class="activity-list" v-if="activities.length > 0">
|
||||
<div class="activity-item" v-for="activity in activities" :key="activity.id">
|
||||
<div class="activity-icon" :class="activity.type">{{ activityIcon(activity.type) }}</div>
|
||||
<div class="activity-content">
|
||||
<p class="activity-text">{{ activity.text }}</p>
|
||||
<span class="activity-time">{{ formatTime(activity.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">📭</span>
|
||||
<p class="empty-text">暂无动态</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<div v-if="showDelete" class="modal-mask" @click.self="cancelDelete">
|
||||
<div class="modal">
|
||||
<div class="modal-icon">⚠️</div>
|
||||
<h3 class="modal-title">删除数字分身</h3>
|
||||
<p class="modal-text">
|
||||
确认删除「{{ pendingDelete?.displayName || pendingDelete?.name }}」?<br />
|
||||
其知识库、问答对、授权等关联数据将一并清除,且<b>不可恢复</b>。
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" @click="cancelDelete">取消</button>
|
||||
<button class="modal-confirm" :disabled="deleting" @click="confirmDelete">
|
||||
{{ deleting ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 当前登录会会用户的资料(头像 / 昵称)
|
||||
const me = computed(() => userStore.user)
|
||||
|
||||
// 状态(来自 store / 后端)
|
||||
const tokenBalance = computed(() => avatarStore.tokenBalance)
|
||||
const avatars = computed(() => avatarStore.avatars)
|
||||
|
||||
// 删除确认弹窗状态
|
||||
const showDelete = ref(false)
|
||||
const pendingDelete = ref<any>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const activities = ref<Array<{ id: string; type: string; text: string; createdAt: string }>>([
|
||||
{ id: '1', type: 'create', text: '数字分身创建成功', createdAt: new Date(Date.now() - 86400000).toISOString() },
|
||||
{ id: '2', type: 'edit', text: '更新了分身描述', createdAt: new Date(Date.now() - 43200000).toISOString() },
|
||||
{ id: '3', type: 'authorize', text: '授权微信小程序访问', createdAt: new Date(Date.now() - 3600000).toISOString() }
|
||||
])
|
||||
|
||||
// 状态文本
|
||||
const statusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'active': '活跃中',
|
||||
'inactive': '未激活',
|
||||
'training': '训练中'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// 活动图标
|
||||
const activityIcon = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'create': '✨',
|
||||
'edit': '✏️',
|
||||
'authorize': '🔑',
|
||||
'interact': '💬'
|
||||
}
|
||||
return map[type] || '📌'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
const date = new Date(time)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
return `${days}天前`
|
||||
}
|
||||
|
||||
// 删除流程
|
||||
const askDelete = (a: any) => {
|
||||
pendingDelete.value = a
|
||||
showDelete.value = true
|
||||
}
|
||||
const cancelDelete = () => {
|
||||
if (deleting.value) return
|
||||
showDelete.value = false
|
||||
pendingDelete.value = null
|
||||
}
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete.value || deleting.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await avatarStore.removeAvatar(pendingDelete.value.id)
|
||||
showDelete.value = false
|
||||
pendingDelete.value = null
|
||||
} catch (e: any) {
|
||||
alert(e?.message || '删除失败,请稍后重试')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导航
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const goToRecharge = () => {
|
||||
router.push('/token/charge')
|
||||
}
|
||||
|
||||
const goCreate = () => {
|
||||
router.push('/avatar/create')
|
||||
}
|
||||
|
||||
const goToKnowledge = () => {
|
||||
router.push('/knowledge')
|
||||
}
|
||||
|
||||
const goToEdit = (id: string) => {
|
||||
router.push(`/avatar/edit/${id}`)
|
||||
}
|
||||
|
||||
const goToChat = (id: string) => {
|
||||
router.push(`/avatar/chat/${id}`)
|
||||
}
|
||||
|
||||
const goToAvatarCard = () => {
|
||||
router.push('/avatar/card')
|
||||
}
|
||||
|
||||
const goToAvatarContacts = () => {
|
||||
router.push('/avatar/contacts')
|
||||
}
|
||||
|
||||
const goToMyProjects = () => {
|
||||
router.push('/avatar/projects')
|
||||
}
|
||||
|
||||
const goToCreateOrg = () => {
|
||||
router.push('/avatar/org/create')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userStore.loadFromStorage()
|
||||
avatarStore.loadAvatars()
|
||||
avatarStore.loadTokenBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-manage-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 用户资料头 */
|
||||
.profile-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 20px 8px;
|
||||
background: #F8F9FA;
|
||||
}
|
||||
.profile-avatar {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #FFB36B, #FF7A1A);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.28);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
.profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.profile-avatar-fallback {
|
||||
font-size: 28px;
|
||||
}
|
||||
.profile-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.profile-name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #18191C;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.profile-sub {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Token 余额条 */
|
||||
.token-section {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.token-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.token-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.token-label {
|
||||
font-size: 13px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.token-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #F97316;
|
||||
}
|
||||
|
||||
.recharge-btn {
|
||||
padding: 8px 16px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.recharge-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 分身列表 */
|
||||
.avatar-list-section {
|
||||
padding: 0 20px 8px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #F97316;
|
||||
background: #FFF0E6;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.avatar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.avatar-photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: #F3F4F6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.avatar-desc {
|
||||
font-size: 13px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: #22C55E;
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: #9398AE;
|
||||
}
|
||||
|
||||
.status-dot.training {
|
||||
background: #F59E0B;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-link {
|
||||
padding: 7px 14px;
|
||||
background: #FFF0E6;
|
||||
color: #F97316;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-link {
|
||||
padding: 7px 14px;
|
||||
background: #F3F4F6;
|
||||
color: #6B7280;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.edit-link:hover {
|
||||
background: #E5E7EB;
|
||||
}
|
||||
|
||||
.del-link {
|
||||
padding: 7px 14px;
|
||||
background: #FEF2F2;
|
||||
color: #EF4444;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.del-link:hover {
|
||||
background: #FEE2E2;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.empty-create-btn {
|
||||
padding: 10px 24px;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
/* 分身工具入口 */
|
||||
.tools-section {
|
||||
padding: 8px 20px 16px;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.tool-arrow {
|
||||
font-size: 18px;
|
||||
color: #C9CDD2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 分身动态列表 */
|
||||
.activities-section {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
font-size: 14px;
|
||||
color: #18191C;
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
/* 删除确认弹窗 */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 50;
|
||||
animation: fade 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: white;
|
||||
border-radius: 18px;
|
||||
padding: 24px 22px 18px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||
animation: pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes pop { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.modal-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #18191C;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #6B7280;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #F3F4F6;
|
||||
color: #6B7280;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-confirm {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #EF4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.modal-confirm:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.modal-confirm:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
157
digital-avatar-app/src/views/CreateOrg.vue
Normal file
157
digital-avatar-app/src/views/CreateOrg.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="create-org-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">创建组织</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 表单 -->
|
||||
<section class="form-section">
|
||||
<div class="form-card">
|
||||
<!-- Logo 选择 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织标识</label>
|
||||
<div class="emoji-picker">
|
||||
<button
|
||||
v-for="e in emojiOptions"
|
||||
:key="e"
|
||||
class="emoji-option"
|
||||
:class="{ active: form.emoji === e }"
|
||||
@click="form.emoji = e"
|
||||
>{{ e }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织名称</label>
|
||||
<input v-model="form.name" class="text-input" placeholder="例如:会会增长团队" maxlength="20" />
|
||||
</div>
|
||||
|
||||
<!-- 简介 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织简介</label>
|
||||
<textarea v-model="form.desc" class="text-area" rows="3" placeholder="一句话介绍这个组织的用途" maxlength="100"></textarea>
|
||||
<span class="char-count">{{ form.desc.length }}/100</span>
|
||||
</div>
|
||||
|
||||
<!-- 类型 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织类型</label>
|
||||
<div class="type-picker">
|
||||
<button
|
||||
v-for="t in typeOptions"
|
||||
:key="t.value"
|
||||
class="type-option"
|
||||
:class="{ active: form.type === t.value }"
|
||||
@click="form.type = t.value"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 提交 -->
|
||||
<section class="submit-section">
|
||||
<button class="submit-btn" :disabled="!canSubmit || submitting" @click="submit">
|
||||
{{ submitting ? '创建中...' : '创建组织' }}
|
||||
</button>
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createOrganization } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const emojiOptions = ['🏢', '🚀', '💡', '🌟', '🔥', '🐳']
|
||||
const typeOptions = [
|
||||
{ value: 'team', label: '团队' },
|
||||
{ value: 'company', label: '企业' },
|
||||
{ value: 'community', label: '社群' }
|
||||
]
|
||||
|
||||
const form = ref({ emoji: '🏢', name: '', desc: '', type: 'team' })
|
||||
|
||||
const canSubmit = computed(() => form.value.name.trim().length > 0)
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit.value || submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await createOrganization({
|
||||
name: form.value.name,
|
||||
desc: form.value.desc,
|
||||
emoji: form.value.emoji,
|
||||
type: form.value.type
|
||||
})
|
||||
toast.value = `组织「${form.value.name}」创建成功`
|
||||
setTimeout(() => router.push('/avatar/manage'), 1200)
|
||||
} catch (e) {
|
||||
toast.value = '创建失败,请重试'
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-org-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
|
||||
.form-section { padding: 16px 20px; }
|
||||
.form-card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.field { margin-bottom: 20px; position: relative; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field-label { display: block; font-size: 14px; font-weight: 600; color: #18191C; margin-bottom: 10px; }
|
||||
|
||||
.emoji-picker { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.emoji-option {
|
||||
width: 44px; height: 44px; border-radius: 12px; border: 2px solid #F3F4F6;
|
||||
background: #F8F9FA; font-size: 22px; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.emoji-option.active { border-color: #F97316; background: #FFF0E6; transform: scale(1.05); }
|
||||
|
||||
.text-input, .text-area {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid #E5E7EB; border-radius: 10px;
|
||||
padding: 12px 14px; font-size: 14px; color: #18191C; outline: none; font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.text-input:focus, .text-area:focus { border-color: #F97316; }
|
||||
.text-area { resize: none; }
|
||||
.char-count { position: absolute; right: 4px; bottom: -18px; font-size: 11px; color: #C9CDD2; }
|
||||
|
||||
.type-picker { display: flex; gap: 10px; }
|
||||
.type-option {
|
||||
flex: 1; padding: 10px; border: 1px solid #E5E7EB; border-radius: 10px;
|
||||
background: white; font-size: 14px; color: #6B7280; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.type-option.active { border-color: #F97316; background: #FFF0E6; color: #F97316; font-weight: 600; }
|
||||
|
||||
.submit-section { padding: 0 20px; }
|
||||
.submit-btn {
|
||||
width: 100%; padding: 14px; background: #F97316; color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.submit-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.submit-btn:not(:disabled):hover { opacity: 0.9; }
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 12px 0 0; }
|
||||
</style>
|
||||
703
digital-avatar-app/src/views/KnowledgeManage.vue
Normal file
703
digital-avatar-app/src/views/KnowledgeManage.vue
Normal file
@@ -0,0 +1,703 @@
|
||||
<template>
|
||||
<div class="knowledge-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">知识库管理</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!avatarId" class="empty-state">
|
||||
<span class="empty-icon">🤖</span>
|
||||
<p class="empty-text">请先创建数字分身后再管理知识库</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="tab-switcher" role="tablist" aria-label="知识库类型">
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ docs.length }}</b></button>
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'qa' }" role="tab" :aria-selected="activeTab === 'qa'" @click="activeTab = 'qa'">标准问答对 <b>{{ qaPairs.length }}</b></button>
|
||||
</div>
|
||||
|
||||
<section v-if="activeTab === 'docs'" class="knowledge-panel">
|
||||
<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-icon">📥</div>
|
||||
<p class="upload-title">拖拽文件到此处,或<span class="upload-link">点击上传</span></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" />
|
||||
</div>
|
||||
<p v-if="uploading" class="uploading-text">上传并向量化中…</p>
|
||||
<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>
|
||||
|
||||
<section class="search-section">
|
||||
<h3 class="section-title">向量检索测试</h3>
|
||||
<div class="search-bar"><input v-model="query" class="search-input" placeholder="输入检索词,测试文档向量召回" @keyup.enter="doSearch" /><button class="search-btn" :disabled="searching" @click="doSearch">检索</button></div>
|
||||
<div v-if="searchResults.length" class="search-results"><div class="search-item" v-for="(r, i) in searchResults" :key="i"><div class="search-head"><span class="search-name">{{ r.filename }}</span><span class="search-score">相似度 {{ r.score }}</span></div><p class="search-snippet">{{ r.snippet }}</p></div></div>
|
||||
<p v-if="searched && !searchResults.length" class="empty-sub-text">未检索到相关内容,先上传文档试试</p>
|
||||
</section>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
import {
|
||||
getKnowledgeDocs,
|
||||
uploadKnowledgeDoc,
|
||||
deleteKnowledgeDoc,
|
||||
getQAPairs,
|
||||
deleteQAPair,
|
||||
searchKnowledge,
|
||||
setQaEnabled
|
||||
} from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAvatarStore()
|
||||
|
||||
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
|
||||
const activeTab = ref<'docs' | 'qa'>('docs')
|
||||
|
||||
const docs = ref<any[]>([])
|
||||
const qaPairs = ref<any[]>([])
|
||||
const uploading = ref(false)
|
||||
const uploadError = ref('')
|
||||
const dragOver = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const query = ref('')
|
||||
const searching = ref(false)
|
||||
const searched = ref(false)
|
||||
const searchResults = ref<any[]>([])
|
||||
|
||||
const loadDocs = async () => {
|
||||
if (!avatarId.value) return
|
||||
try {
|
||||
const res: any = await getKnowledgeDocs(avatarId.value)
|
||||
docs.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadQA = async () => {
|
||||
if (!avatarId.value) return
|
||||
try {
|
||||
const res: any = await getQAPairs(avatarId.value)
|
||||
qaPairs.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFile = () => fileInput.value?.click()
|
||||
|
||||
const onFileChange = (e: Event) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0]
|
||||
if (f) doUpload(f)
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
dragOver.value = false
|
||||
const f = e.dataTransfer?.files?.[0]
|
||||
if (f) doUpload(f)
|
||||
}
|
||||
|
||||
const doUpload = async (file: File) => {
|
||||
uploadError.value = ''
|
||||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
|
||||
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
|
||||
return
|
||||
}
|
||||
if (!avatarId.value) {
|
||||
uploadError.value = '请先创建数字分身'
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadKnowledgeDoc(avatarId.value, file)
|
||||
await loadDocs()
|
||||
} catch (e: any) {
|
||||
uploadError.value = e?.message || '上传失败'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeDoc = async (id: string) => {
|
||||
if (!avatarId.value) return
|
||||
await deleteKnowledgeDoc(avatarId.value, id)
|
||||
await loadDocs()
|
||||
}
|
||||
|
||||
const doSearch = async () => {
|
||||
if (!avatarId.value || !query.value.trim()) return
|
||||
searching.value = true
|
||||
searched.value = true
|
||||
try {
|
||||
const res: any = await searchKnowledge(avatarId.value, query.value.trim(), 5)
|
||||
searchResults.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleQa = async (qa: any, e: Event) => {
|
||||
const enabled = (e.target as HTMLInputElement).checked
|
||||
qa.enabled = enabled
|
||||
if (!avatarId.value) return
|
||||
try {
|
||||
await setQaEnabled(avatarId.value, qa.id, enabled)
|
||||
} catch (err) {
|
||||
qa.enabled = !enabled
|
||||
;(e.target as HTMLInputElement).checked = !enabled
|
||||
console.error('切换启用状态失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const goAddQa = () => router.push('/knowledge/qa/create')
|
||||
|
||||
const goEditQa = (qa: any) => router.push(`/knowledge/qa/${qa.id}/edit`)
|
||||
|
||||
const removeQa = async (id: string) => {
|
||||
if (!avatarId.value) return
|
||||
await deleteQAPair(avatarId.value, id)
|
||||
await loadQA()
|
||||
}
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const formatSize = (n: number) => {
|
||||
if (n < 1024) return n + ' B'
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||
return (n / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString('zh-CN') : '-'
|
||||
|
||||
const fileEmoji = (t: string) => (['xlsx'].includes(t) ? '📊' : '📄')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.avatars.length) {
|
||||
await store.loadAvatars()
|
||||
}
|
||||
await Promise.all([loadDocs(), loadQA()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tab-switcher {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #F4D8BE;
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: #FFF8F1;
|
||||
color: #8B6B58;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 上传区 */
|
||||
.upload-section {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 28px 20px;
|
||||
background: white;
|
||||
border: 2px dashed #FCD9B6;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.upload-zone.drag-over {
|
||||
border-color: #F97316;
|
||||
background: #FFF7EF;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-size: 14px;
|
||||
color: #18191C;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-link {
|
||||
color: #F97316;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.uploading-text {
|
||||
font-size: 13px;
|
||||
color: #F97316;
|
||||
text-align: center;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
color: #EF4444;
|
||||
text-align: center;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
/* 文档列表 */
|
||||
.docs-section,
|
||||
.qa-section,
|
||||
.search-section {
|
||||
padding: 0 20px 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.doc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.doc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.badge-vec {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #16A34A;
|
||||
background: #ECFDF3;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.badge-vec.pending {
|
||||
color: #D97706;
|
||||
background: #FFFAEB;
|
||||
}
|
||||
|
||||
.doc-del {
|
||||
padding: 6px 12px;
|
||||
background: #FEF2F2;
|
||||
color: #EF4444;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 检索测试 */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
color: #18191C;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
width: 72px;
|
||||
height: 38px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-item {
|
||||
padding: 12px 14px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.search-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.search-score {
|
||||
font-size: 12px;
|
||||
color: #F97316;
|
||||
}
|
||||
|
||||
.search-snippet {
|
||||
font-size: 12px;
|
||||
color: #6B7280;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 问答对 */
|
||||
.qa-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qa-head .section-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-qa-btn {
|
||||
width: 72px;
|
||||
height: 38px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qa-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qa-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.qa-item.qa-disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.qa-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qa-question {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.qa-answer {
|
||||
font-size: 13px;
|
||||
color: #6B7280;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.qa-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qa-edit {
|
||||
padding: 5px 12px;
|
||||
background: #FFF0E6;
|
||||
color: #F97316;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qa-del {
|
||||
padding: 5px 12px;
|
||||
background: #FEF2F2;
|
||||
color: #EF4444;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 开关 */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: #E5E7EB;
|
||||
border-radius: 22px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background: #F97316;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state,
|
||||
.empty-sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 16px 20px;
|
||||
}
|
||||
|
||||
.docs-section .empty-sub,
|
||||
.qa-section .empty-sub {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
/* 上传区空态无需额外 margin,.upload-zone 已占满 section content 宽度 */
|
||||
.empty-icon,
|
||||
.empty-sub-icon {
|
||||
font-size: 44px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-text,
|
||||
.empty-sub-text {
|
||||
font-size: 14px;
|
||||
color: #9398AE;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
100
digital-avatar-app/src/views/MyProjects.vue
Normal file
100
digital-avatar-app/src/views/MyProjects.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="projects-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">我的项目</h1>
|
||||
</div>
|
||||
<span class="count-badge">{{ projects.length }}</span>
|
||||
</header>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<section class="projects-section">
|
||||
<div class="project-list" v-if="projects.length > 0">
|
||||
<div class="project-card" v-for="p in projects" :key="p.id">
|
||||
<div class="project-top">
|
||||
<div class="project-emoji">{{ p.emoji }}</div>
|
||||
<div class="project-head">
|
||||
<h2 class="project-name">{{ p.name }}</h2>
|
||||
<span class="role-badge" :class="p.role">{{ roleText(p.role) }}</span>
|
||||
</div>
|
||||
<span class="status-dot" :class="p.status"></span>
|
||||
</div>
|
||||
<p class="project-desc">{{ p.desc }}</p>
|
||||
<div class="project-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: p.progress + '%' }"></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ p.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">📁</span>
|
||||
<p class="empty-text">分身暂未参与任何项目</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface Project { id: string; emoji: string; name: string; role: string; desc: string; status: string; progress: number }
|
||||
const projects = ref<Project[]>([
|
||||
{ id: '1', emoji: '🚀', name: '会会增长计划', role: 'owner', desc: '负责品牌分身的对话策略与内容生成', status: 'active', progress: 72 },
|
||||
{ id: '2', emoji: '📊', name: '用户访谈分析', role: 'member', desc: '协助整理 200 份访谈记录并提炼洞察', status: 'active', progress: 45 },
|
||||
{ id: '3', emoji: '🎯', name: '私域运营 SOP', role: 'viewer', desc: '只读权限,跟进流程文档', status: 'paused', progress: 20 }
|
||||
])
|
||||
|
||||
const roleText = (r: string) => ({ owner: '负责人', member: '成员', viewer: '访客' }[r] || '成员')
|
||||
|
||||
const goBack = () => router.back()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.projects-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.count-badge { background: rgba(255,255,255,0.2); padding: 2px 12px; border-radius: 20px; font-size: 14px; }
|
||||
|
||||
.projects-section { padding: 16px 20px; }
|
||||
.project-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.project-card { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.project-top { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.project-emoji {
|
||||
width: 44px; height: 44px; border-radius: 12px; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.project-head { flex: 1; min-width: 0; }
|
||||
.project-name { font-size: 15px; font-weight: 600; margin: 0 0 4px; color: #18191C; }
|
||||
.role-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.role-badge.owner { background: #FFF0E6; color: #F97316; }
|
||||
.role-badge.member { background: #EFF6FF; color: #3B82F6; }
|
||||
.role-badge.viewer { background: #F3F4F6; color: #6B7280; }
|
||||
.status-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.active { background: #22C55E; }
|
||||
.status-dot.paused { background: #F59E0B; }
|
||||
.project-desc { font-size: 13px; color: #6B7280; line-height: 1.5; margin: 0 0 12px; }
|
||||
.project-progress { display: flex; align-items: center; gap: 10px; }
|
||||
.progress-bar { flex: 1; height: 6px; background: #F3F4F6; border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #F97316, #FB923C); border-radius: 3px; transition: width 0.4s ease; }
|
||||
.progress-text { font-size: 12px; color: #9398AE; flex-shrink: 0; }
|
||||
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 40px 20px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9398AE; margin: 0; }
|
||||
</style>
|
||||
304
digital-avatar-app/src/views/QaPairEdit.vue
Normal file
304
digital-avatar-app/src/views/QaPairEdit.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="qa-edit-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">{{ isEdit ? '编辑问答对' : '添加问答对' }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="form-section">
|
||||
<label class="field-label">问题</label>
|
||||
<textarea
|
||||
v-model="form.question"
|
||||
class="field-input"
|
||||
rows="3"
|
||||
placeholder="例如:你们的退款政策是什么?"
|
||||
></textarea>
|
||||
|
||||
<label class="field-label">标准答案</label>
|
||||
<textarea
|
||||
v-model="form.answer"
|
||||
class="field-input"
|
||||
rows="5"
|
||||
placeholder="输入该问题的标准回答"
|
||||
></textarea>
|
||||
|
||||
<div class="switch-row">
|
||||
<div class="switch-text">
|
||||
<span class="switch-title">启用</span>
|
||||
<span class="switch-desc">关闭后该问答对不参与分身作答</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="form.enabled" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-text">{{ error }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-cancel" @click="goBack">取消</button>
|
||||
<button class="btn-save" :disabled="saving" @click="save">保存</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
import { getQAPairs, createQAPair, updateQAPair } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAvatarStore()
|
||||
|
||||
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
|
||||
const qaId = computed(() => (route.params.qaId as string) || null)
|
||||
const isEdit = computed(() => !!qaId.value)
|
||||
|
||||
const form = reactive({ question: '', answer: '', enabled: true })
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const loadForEdit = async () => {
|
||||
if (!avatarId.value || !qaId.value) return
|
||||
try {
|
||||
const res: any = await getQAPairs(avatarId.value)
|
||||
const list: any[] = unwrapListData(res)
|
||||
const target = list.find((q) => q.id === qaId.value)
|
||||
if (target) {
|
||||
form.question = target.question
|
||||
form.answer = target.answer
|
||||
form.enabled = target.enabled !== false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
error.value = ''
|
||||
if (!avatarId.value) {
|
||||
error.value = '请先创建数字分身'
|
||||
return
|
||||
}
|
||||
if (!form.question.trim() || !form.answer.trim()) {
|
||||
error.value = '请填写问题和答案'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
question: form.question.trim(),
|
||||
answer: form.answer.trim(),
|
||||
enabled: form.enabled
|
||||
}
|
||||
if (isEdit.value && qaId.value) {
|
||||
await updateQAPair(avatarId.value, qaId.value, payload)
|
||||
} else {
|
||||
await createQAPair(avatarId.value, payload)
|
||||
}
|
||||
// 保存成功返回知识库管理页
|
||||
router.replace('/knowledge')
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.avatars.length) {
|
||||
await store.loadAvatars()
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await loadForEdit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qa-edit-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
color: #18191C;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.switch-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.switch-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.switch-desc {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
/* 开关 */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: #E5E7EB;
|
||||
border-radius: 24px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background: #F97316;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
color: #EF4444;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #F3F4F6;
|
||||
color: #6B7280;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
536
digital-avatar-app/src/views/SmsLogin.vue
Normal file
536
digital-avatar-app/src/views/SmsLogin.vue
Normal file
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="glow glow-top" aria-hidden="true" />
|
||||
<div class="glow glow-bottom" aria-hidden="true" />
|
||||
|
||||
<div class="login-content">
|
||||
<div class="brand">
|
||||
<div class="brand-logo" aria-hidden="true">
|
||||
<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="brand-grad" x1="0" y1="0" x2="88" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFB36B" />
|
||||
<stop offset="1" stop-color="#FF7A1A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="44" cy="44" r="42" fill="url(#brand-grad)" />
|
||||
<circle cx="44" cy="44" r="42" fill="none" stroke="#FFFFFF" stroke-opacity="0.5" stroke-width="2" />
|
||||
<rect x="26" y="30" width="36" height="32" rx="10" fill="#FFFFFF" />
|
||||
<circle cx="37" cy="46" r="3.5" fill="#FF7A1A" />
|
||||
<circle cx="51" cy="46" r="3.5" fill="#FF7A1A" />
|
||||
<rect x="36" y="53" width="16" height="3.5" rx="1.75" fill="#FF7A1A" />
|
||||
<rect x="42" y="18" width="4" height="10" rx="2" fill="#FFFFFF" />
|
||||
<circle cx="44" cy="16" r="4" fill="#FFFFFF" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="brand-title">会会数字分身</h1>
|
||||
<p class="brand-sub">你的专属 AI 数字分身</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换:默认账号密码 -->
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'password' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'password'"
|
||||
@click="activeTab = 'password'"
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'sms' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'sms'"
|
||||
@click="activeTab = 'sms'"
|
||||
>
|
||||
短信登录
|
||||
</button>
|
||||
<span class="tab-indicator" :class="activeTab" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录 -->
|
||||
<form v-if="activeTab === 'password'" class="form" @submit.prevent="onPwdLogin" novalidate>
|
||||
<div class="input-card">
|
||||
<input
|
||||
v-model="account"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
placeholder="手机号 / 会会账号"
|
||||
aria-label="账号"
|
||||
@input="errorMsg = ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-card">
|
||||
<input
|
||||
v-model="password"
|
||||
class="input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
aria-label="密码"
|
||||
@input="errorMsg = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="!canPwdLogin || loading">
|
||||
{{ loading ? '登录中...' : '登录 / 创建分身' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 短信验证码登录 -->
|
||||
<form v-else class="form" @submit.prevent="onSmsLogin" novalidate>
|
||||
<div class="input-card">
|
||||
<span class="prefix" aria-hidden="true">+86</span>
|
||||
<span class="divider" aria-hidden="true" />
|
||||
<input
|
||||
v-model="phone"
|
||||
class="input"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
aria-label="手机号"
|
||||
@input="onPhoneInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-card code-card">
|
||||
<input
|
||||
v-model="code"
|
||||
class="input"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入验证码"
|
||||
aria-label="验证码"
|
||||
@input="onCodeInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="code-btn"
|
||||
:disabled="!canSend || counting"
|
||||
@click="onSendCode"
|
||||
>
|
||||
{{ counting ? `${countdown}s 后重发` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="!canLogin || loading">
|
||||
{{ loading ? '登录中...' : '登录 / 创建分身' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="footer">
|
||||
<p class="footer-hint">
|
||||
{{ activeTab === 'password' ? '使用会会账号密码登录,首次登录将自动创建分身' : '未注册的手机号验证后将自动创建分身账号' }}
|
||||
</p>
|
||||
<p class="footer-agree">登录即代表同意《用户协议》与《隐私政策》</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const avatarStore = useAvatarStore()
|
||||
|
||||
const activeTab = ref<'password' | 'sms'>('password')
|
||||
|
||||
// 短信登录
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
|
||||
// 密码登录
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const counting = ref(false)
|
||||
const countdown = ref(60)
|
||||
let timer: any = null
|
||||
|
||||
const canSend = computed(() => /^1\d{10}$/.test(phone.value))
|
||||
const canLogin = computed(() => canSend.value && /^\d{4,6}$/.test(code.value))
|
||||
const canPwdLogin = computed(() => account.value.trim().length > 0 && password.value.length > 0)
|
||||
|
||||
const onPhoneInput = () => {
|
||||
phone.value = phone.value.replace(/\D/g, '').slice(0, 11)
|
||||
errorMsg.value = ''
|
||||
}
|
||||
const onCodeInput = () => {
|
||||
code.value = code.value.replace(/\D/g, '').slice(0, 6)
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
const startCountdown = () => {
|
||||
counting.value = true
|
||||
countdown.value = 60
|
||||
timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
counting.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const onSendCode = async () => {
|
||||
if (!canSend.value || counting.value) return
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await userStore.sendCode(phone.value)
|
||||
startCountdown()
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '验证码发送失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
const afterLogin = (res: any) => {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: res.huihui?.userId || res.user?.huihuiUserId || '',
|
||||
nickname: res.huihui?.nickname || res.user?.nickname || '',
|
||||
avatarUrl: res.huihui?.avatarUrl || res.user?.avatarUrl || ''
|
||||
})
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.replace(redirect)
|
||||
}
|
||||
|
||||
const onSmsLogin = async () => {
|
||||
if (!canLogin.value || loading.value) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res: any = await userStore.login(phone.value, code.value)
|
||||
afterLogin(res)
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '登录失败,请检查验证码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onPwdLogin = async () => {
|
||||
if (!canPwdLogin.value || loading.value) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res: any = await userStore.loginByPwd(account.value.trim(), password.value)
|
||||
afterLogin(res)
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '登录失败,请检查账号或密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding-top: calc(20px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
overflow: hidden;
|
||||
background: #fff6ec;
|
||||
animation: fadeIn 0.45s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(50px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.glow-top {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
top: -90px;
|
||||
right: -40px;
|
||||
background: rgba(255, 179, 107, 0.5);
|
||||
}
|
||||
|
||||
.glow-bottom {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
bottom: -120px;
|
||||
left: -110px;
|
||||
background: rgba(255, 205, 166, 0.55);
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
filter: drop-shadow(0 12px 24px rgba(255, 122, 26, 0.3));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #4a2511;
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #a9744f;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── 分段切换 ── */
|
||||
.tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: inset 0 1px 2px rgba(74, 37, 17, 0.05);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #a9744f;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #4a2511;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
width: calc(50% - 5px);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(74, 37, 17, 0.1);
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tab-indicator.password {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.tab-indicator.sms {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* ── 表单 ── */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
animation: formIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes formIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.input-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 56px;
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(74, 37, 17, 0.06),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.8);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.input-card:focus-within {
|
||||
border-color: rgba(255, 140, 66, 0.65);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(74, 37, 17, 0.08),
|
||||
0 0 0 3px rgba(255, 140, 66, 0.15),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.prefix {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #4a2511;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(74, 37, 17, 0.15);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
color: #4a2511;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #a9744f;
|
||||
}
|
||||
|
||||
.code-card {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
flex-shrink: 0;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #ff8c42;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.code-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.code-btn:disabled {
|
||||
background: #f3d2c3;
|
||||
color: #fff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: -4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ff8c42 0%, #f96e1c 100%);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 20px rgba(255, 140, 66, 0.45);
|
||||
transition: transform 0.15s, opacity 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
background: #e5e7eb;
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #a9744f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-agree {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #c9a88e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
378
digital-avatar-app/src/views/TokenCharge.vue
Normal file
378
digital-avatar-app/src/views/TokenCharge.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="token-charge-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">Token 充值</h1>
|
||||
<div class="header-right"></div>
|
||||
</header>
|
||||
|
||||
<!-- 当前余额 -->
|
||||
<section class="balance-section">
|
||||
<div class="balance-card">
|
||||
<span class="balance-label">当前余额</span>
|
||||
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
|
||||
<span class="balance-unit">Token</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 充值套餐 -->
|
||||
<section class="plans-section">
|
||||
<h3 class="section-title">选择充值套餐</h3>
|
||||
<div class="plan-list">
|
||||
<div
|
||||
class="plan-card"
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
:class="{ selected: selectedPlan?.id === plan.id }"
|
||||
@click="selectedPlan = plan"
|
||||
>
|
||||
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
||||
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div>
|
||||
<div class="plan-unit">Token</div>
|
||||
<div class="plan-price">¥{{ plan.price }}</div>
|
||||
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 支付方式 -->
|
||||
<section class="payment-section">
|
||||
<h3 class="section-title">支付方式</h3>
|
||||
<div class="payment-list">
|
||||
<div
|
||||
class="payment-card"
|
||||
:class="{ selected: paymentMethod === 'wechat' }"
|
||||
@click="paymentMethod = 'wechat'"
|
||||
>
|
||||
<span class="payment-icon">💚</span>
|
||||
<span class="payment-name">微信支付</span>
|
||||
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
||||
</div>
|
||||
<div
|
||||
class="payment-card"
|
||||
:class="{ selected: paymentMethod === 'alipay' }"
|
||||
@click="paymentMethod = 'alipay'"
|
||||
>
|
||||
<span class="payment-icon">💙</span>
|
||||
<span class="payment-name">支付宝</span>
|
||||
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 充值按钮 -->
|
||||
<section class="checkout-section">
|
||||
<button
|
||||
class="checkout-btn"
|
||||
:class="{ disabled: !selectedPlan }"
|
||||
:disabled="!selectedPlan"
|
||||
@click="doCharge"
|
||||
>
|
||||
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 当前余额
|
||||
const currentBalance = ref<number>(1250)
|
||||
|
||||
// 充值套餐
|
||||
const plans = ref<Array<{
|
||||
id: string
|
||||
amount: number
|
||||
price: number
|
||||
badge?: string
|
||||
desc?: string
|
||||
}>>([])
|
||||
|
||||
// 选中的套餐
|
||||
const selectedPlan = ref<any>(null)
|
||||
|
||||
// 支付方式
|
||||
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
||||
|
||||
// 从后端加载余额与套餐
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const b: any = await getTokenBalance()
|
||||
currentBalance.value = b?.balance ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
try {
|
||||
const p: any = await getRechargePlans()
|
||||
plans.value = p || []
|
||||
} catch (e) {
|
||||
console.error('加载套餐失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行充值(写入后端)
|
||||
const charging = ref(false)
|
||||
const doCharge = async () => {
|
||||
if (!selectedPlan.value || charging.value) return
|
||||
charging.value = true
|
||||
try {
|
||||
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
|
||||
const res: any = await chargeToken(selectedPlan.value.id)
|
||||
currentBalance.value = res?.balance ?? currentBalance.value
|
||||
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`)
|
||||
} catch (e) {
|
||||
alert('充值失败,请重试')
|
||||
} finally {
|
||||
charging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.token-charge-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
/* 当前余额 */
|
||||
.balance-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.balance-unit {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 充值套餐 */
|
||||
.plans-section {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
position: relative;
|
||||
padding: 20px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
border: 2px solid #EDEEF1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plan-card:hover {
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.plan-card.selected {
|
||||
border-color: #F97316;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 0 12px 0 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #18191C;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plan-unit {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #F97316;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plan-desc {
|
||||
font-size: 11px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
/* 支付方式 */
|
||||
.payment-section {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.payment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.payment-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #EDEEF1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.payment-card:hover {
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.payment-card.selected {
|
||||
border-color: #F97316;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.payment-name {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.payment-check {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 充值按钮 */
|
||||
.checkout-section {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.checkout-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkout-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.checkout-btn.disabled {
|
||||
background: #D1D5DB;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkout-btn.disabled:hover {
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
21
digital-avatar-app/tsconfig.json
Normal file
21
digital-avatar-app/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
25
digital-avatar-app/vite.config.ts
Normal file
25
digital-avatar-app/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// 后端 API 代理目标(仅 dev / vite 代理生效,生产构建走 nginx 或 __APP_CONFIG__.apiBase)
|
||||
// 默认指向已部署的远端后端 192.168.1.188:8011;
|
||||
// 若在本机另起后端,可在启动前覆盖:export VITE_API_PROXY_TARGET=http://localhost:8000
|
||||
const API_PROXY_TARGET = process.env.VITE_API_PROXY_TARGET || 'http://192.168.1.188:8011'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_PROXY_TARGET,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
96
digital-avatar-app/工作总结-2026-07-08.md
Normal file
96
digital-avatar-app/工作总结-2026-07-08.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 数字分身管理系统 · 今日工作总结
|
||||
|
||||
**日期**:2026-07-08
|
||||
**负责人**:Senior Developer(高级开发工程师)
|
||||
**项目目录**:`digital-avatar-app`
|
||||
**技术栈**:Vue3 + Vite + TypeScript + Pinia + Vue Router / FastAPI + SQLAlchemy + SQLite
|
||||
|
||||
---
|
||||
|
||||
## 一、项目背景与目标
|
||||
|
||||
- **产品线**:「会会」(HuiHui)AI 个人数字分身(云 SaaS 版)
|
||||
- **需求来源**:《AI 个人数字分身系统研发需求文档(PRD)》+ Ardot 设计稿(9 个页面)
|
||||
- **部署形态**:uni-app 封装为 App / 微信小程序 / H5(当前阶段先实现 Web 版,验证业务闭环)
|
||||
- **核心目标**:打通「创建分身 → 管理分身 → 工具/授权/充值」的完整用户路径,并完成前后端真实数据贯通
|
||||
|
||||
---
|
||||
|
||||
## 二、今日完成的工作
|
||||
|
||||
### 1. 修复页面空白 & 首页改造
|
||||
- 定位并修复页面空白三大根因:Pinia store 文件缺失、4 个视图文件为空(0 字节)、`main.ts` 导入了不存在的 store
|
||||
- 将首页(`/`)改为「创建分身」引导页(渐变区 + 功能亮点卡片 + CTA)
|
||||
- 首页「开始创建」CTA 接入 4 步向导(原为死 `alert`)
|
||||
|
||||
### 2. 创建分身 4 步向导
|
||||
- 步骤:① 基础信息 → ② 形象风格(emoji + 回复风格)→ ③ 灵魂配置(创造力/严谨度/幽默感滑块 + 回复长度 + 系统提示词)→ ④ 确认创建
|
||||
- 数据流打通:向导提交 → `avatarStore.addAvatar()` → `router.push('/avatar/manage')` → 管理页展示刚创建的分身(头像用所选 emoji)
|
||||
- 管理页右上角新增「+创建」入口,可随时再建
|
||||
|
||||
### 3. 分身工具 4 个子页面
|
||||
- 新增:**分身名片 / 分身人脉 / 我的项目 / 创建组织** 四个页面
|
||||
- 管理页「分身工具」区 4 个入口从 `console.log` 改为真实 `router.push` 跳转
|
||||
- 底部导航栏 3 入口(我的分身 / 授权管理 / Token),并在首页与编辑页自动隐藏,避免遮挡内容
|
||||
|
||||
### 4. FastAPI 后端搭建(核心交付)
|
||||
- **统一响应包装** `{code, message, data}`(`responses.py` 提供 `ok/fail`),与前端拦截器解包对齐
|
||||
- **数据模型**:`Avatar / Authorization / Organization / TokenAccount / TokenPlan`,`to_dict()` 输出 camelCase 字段(displayName / photoUrl / createdAt …)匹配前端类型
|
||||
- **接口清单**(均挂 `/api` 前缀):
|
||||
- `GET/POST /avatar`、`GET/PUT/DELETE /avatar/:id`
|
||||
- `GET /token/balance`、`GET /token/plans`、`POST /token/charge`
|
||||
- `GET/PUT /avatar/:id/authorizations`
|
||||
- `GET/POST /organizations`
|
||||
- **种子数据**:默认余额 1250、4 个充值套餐、1 个分身、3 条授权、2 个组织
|
||||
- 依赖装在隔离 venv:`/Users/yqq/.workbuddy/binaries/python/envs/default`
|
||||
|
||||
### 5. 前后端接通(端到端跑通)
|
||||
- `src/api/index.ts`:响应拦截器解包 `{code,message,data}`(code===200 返回 data);新增 `chargeToken`、`createOrganization`
|
||||
- `src/store/avatar.ts`:改为异步 `loadAvatars() / loadTokenBalance() / addAvatar()` 调真实接口
|
||||
- `AvatarManage`:Token 余额 / 分身卡片来自后端
|
||||
- `AvatarCreate`:`submit` 改 async 调 `addAvatar`(含 submitting 态)
|
||||
- `AuthorizationManage`:拉取 + 切换授权状态写后端
|
||||
- `TokenCharge`:`loadData()` 拉余额/套餐,`doCharge` 真实充值(余额回写)
|
||||
- `CreateOrg`:`submit` 调 `createOrganization`(含 submitting 态)
|
||||
|
||||
---
|
||||
|
||||
## 三、验证结果
|
||||
|
||||
| 验证项 | 结果 |
|
||||
|--------|------|
|
||||
| 后端接口 curl(含写操作) | 全部通过;充值 1250 → 2250 正确回写 |
|
||||
| Vite 代理 `/api/* → 8000` | 正常 |
|
||||
| 前端改动模块 Vite 转译 | 0 报错 |
|
||||
| DB 状态 | 已重置为干净种子态(balance 1250,1 分身) |
|
||||
| 生产构建(`vite build`) | 打包成功(主包 gzip 39KB / 1.43s) |
|
||||
|
||||
> ⚠️ 注:`npm run build` 中的 `vue-tsc` 因旧版本与 Node 22 不兼容(patch TS 内部字符串失败)报错,属工具链问题,非代码错误;已用 `vite build` 跳过类型检查确认打包无误。
|
||||
|
||||
---
|
||||
|
||||
## 四、当前架构(见附图)
|
||||
|
||||
端到端链路:**会会多端(App/小程序/H5)→ Vue3 SPA → Vite 代理 /api → FastAPI → SQLite**
|
||||
|
||||
---
|
||||
|
||||
## 五、遗留 & 下一步
|
||||
|
||||
1. **分身名片 / 人脉 / 项目** 3 个子页仍用前端 mock,待接真实接口(如 `getOrganizationList` 等)
|
||||
2. **编辑分身 / 删除分身** 接口待补全并接入
|
||||
3. **uni-app 封装**(App / 微信小程序 / H5)待启动,复用当前 Web 版组件
|
||||
4. **生产级类型检查**:升级 `vue-tsc` 或调整构建脚本,消除 Node 22 兼容告警
|
||||
5. **鉴权与多租户**:当前后端为单用户种子态,需补充用户登录与数据隔离
|
||||
|
||||
---
|
||||
|
||||
## 六、可复用模式(建议沉淀为 Skill)
|
||||
|
||||
「Vue3 + Vite 前端 / FastAPI 后端」脚手架约定:
|
||||
- Vite `server.proxy['/api'] → localhost:8000`
|
||||
- 后端统一 `{code, message, data}` 响应
|
||||
- 前端响应拦截器解包(code===200 返回 data)
|
||||
- SQLAlchemy 模型 `to_dict()` 输出 camelCase 字段,对齐前端 TS 类型
|
||||
|
||||
> 说明:本轮附带的 `ardot-design-core` / `ardot-design-router` 为 Ardot 画布操作型技能,依赖 Ardot MCP(当前环境未连接),故其设计规范以「橙色主题 / 卡片层次 / 渐进式向导路由」形式落地为 Vue 代码,未在画布上直接设计。
|
||||
169
docs/production-interface-inventory.md
Normal file
169
docs/production-interface-inventory.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# 生产环境接口清单
|
||||
|
||||
更新时间:2026-06-23
|
||||
|
||||
本文档整理的是本项目当前代码里会主动调用的外部生产环境接口,以及当前运行环境通过系统配置接口读到的真实生产地址。
|
||||
|
||||
## 当前生产配置
|
||||
|
||||
- 认证服务基址:`https://99hui.com/api/usercenter`
|
||||
- 新闻业务基址:`https://99hui.com/api/huihuibusiness`
|
||||
- 文件上传基址:`https://99hui.com/api/filecenter`
|
||||
说明:代码通过把 `huihuibusiness` 替换成 `filecenter` 推导得到。
|
||||
- 当前默认 AI 模型:
|
||||
- 模型名:`阿里千问大模型`
|
||||
- 提供方:`qianwen`
|
||||
- API Base URL:`https://dashscope.aliyuncs.com/compatible-mode/v1`
|
||||
- 模型版本:`qwen-plus`
|
||||
|
||||
## 认证服务
|
||||
|
||||
基址:`https://99hui.com/api/usercenter`
|
||||
|
||||
### `POST /open/login/token`
|
||||
|
||||
- 用途:虚拟用户账号密码登录,换取 `access_token`
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:149)
|
||||
- [system.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/api/endpoints/system.py:86)
|
||||
|
||||
### `PATCH /v2/users/current`
|
||||
|
||||
- 用途:同步修改目标平台用户资料
|
||||
- 涉及字段:昵称、真实姓名、性别、头像、简介、邮箱
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:946)
|
||||
|
||||
## 新闻业务服务
|
||||
|
||||
基址:`https://99hui.com/api/huihuibusiness`
|
||||
|
||||
### `GET /app/user/info`
|
||||
|
||||
- 用途:登录成功后探测用户所属组织 `orgId`
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250)
|
||||
|
||||
### `GET /open/user/info`
|
||||
|
||||
- 用途:登录成功后探测用户所属组织 `orgId`
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250)
|
||||
|
||||
### `GET /user/info`
|
||||
|
||||
- 用途:登录成功后探测用户所属组织 `orgId`
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250)
|
||||
|
||||
### `GET /news/list`
|
||||
|
||||
- 用途:会话有效性校验
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:323)
|
||||
|
||||
### `GET /business/square/list`
|
||||
|
||||
- 用途:
|
||||
- 统计今日文章数量
|
||||
- 拉取今日广场文章
|
||||
- 拉取历史广场文章
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:350)
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:483)
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:620)
|
||||
|
||||
### `GET /news/{article_id}`
|
||||
|
||||
- 用途:校验文章是否存在、正文是否足够长
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:396)
|
||||
|
||||
### `PATCH /news/read/{news_id}`
|
||||
|
||||
- 用途:模拟用户阅读新闻
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:711)
|
||||
|
||||
### `POST /message/comment`
|
||||
|
||||
- 用途:发表评论
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:734)
|
||||
|
||||
### `GET /message/comment`
|
||||
|
||||
- 用途:获取评论列表,为回复链路提供上下文
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:759)
|
||||
|
||||
### `POST /message/comment/reply`
|
||||
|
||||
- 用途:回复评论
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:747)
|
||||
|
||||
### `POST /message/praise`
|
||||
|
||||
- 用途:
|
||||
- 点赞
|
||||
- 收藏
|
||||
说明:当前收藏复用了点赞接口
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:771)
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:784)
|
||||
|
||||
### `GET /news/share/wechat/{news_id}`
|
||||
|
||||
- 用途:转发前的微信分享预热调用
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:796)
|
||||
|
||||
### `POST /points/forward/news/{org_id}`
|
||||
|
||||
- 用途:真正执行转发积分动作
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:802)
|
||||
|
||||
### `DELETE /message/praise/cancel`
|
||||
|
||||
- 用途:取消点赞 / 取消收藏
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:830)
|
||||
|
||||
### `DELETE /message/comment/{news_id}/{comment_id}`
|
||||
|
||||
- 用途:删除评论
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:865)
|
||||
|
||||
## 文件服务
|
||||
|
||||
基址:`https://99hui.com/api/filecenter`
|
||||
|
||||
### `POST /fileUpload`
|
||||
|
||||
- 用途:上传用户头像到平台文件中心
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:1002)
|
||||
|
||||
## AI 模型服务
|
||||
|
||||
当前默认基址:`https://dashscope.aliyuncs.com/compatible-mode/v1`
|
||||
|
||||
### `POST /chat/completions`
|
||||
|
||||
- 用途:
|
||||
- 生成人格
|
||||
- 生成评论
|
||||
- 生成回复
|
||||
- 测试模型连通性
|
||||
- 代码位置:
|
||||
- [ai_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/ai_service.py:70)
|
||||
- [ai_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/ai_service.py:215)
|
||||
|
||||
## 说明
|
||||
|
||||
- `resource.99hui.com` 当前主要出现在头像上传成功后的返回 URL 中,属于资源访问域名,不是本项目后端主动调用的 API。
|
||||
- AI 服务基址不是写死生产地址,而是读数据库里的模型配置;如果后台切换默认模型,这一节需要随之更新。
|
||||
- 当前系统配置里 `platform_org_id` 为空,这和“部分用户点立即互动时取不到文章列表”现象高度相关,建议后续继续排查组织信息自动发现链路。
|
||||
319
docs/superpowers/plans/2026-07-23-avatar-chat-knowledge-plan.md
Normal file
319
docs/superpowers/plans/2026-07-23-avatar-chat-knowledge-plan.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# 数字分身聊天与知识库增强实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现“标准问答对 > 文件知识库 > OpenAI 兼容 Qwen”的数字分身聊天链路,并补齐 MD/TXT 文档、人格编辑配置和知识库双表界面。
|
||||
|
||||
**Architecture:** 后端新增聊天编排接口,统一负责用户/分身权限、标准问答匹配、向量召回和 Qwen 调用;前端只传递消息历史并展示答案来源。现有 SQLite/SQLAlchemy 数据模型继续复用,聊天历史暂不持久化,Qwen 只通过后端环境变量访问。
|
||||
|
||||
**Tech Stack:** FastAPI、SQLAlchemy、Python `unittest`/`unittest.mock`、Vue 3 Composition API、Vue Router、Pinia、Axios、Vite。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 回复优先级固定为标准问答对 > 文件知识库 > Qwen。
|
||||
- Qwen 配置只读取后端 `CHAT_API_URL`、`CHAT_API_KEY`、`CHAT_MODEL`,前端不得接触密钥。
|
||||
- 所有聊天、知识库、问答操作必须校验 Bearer app token 对应的分身归属。
|
||||
- 文档支持 `.md`、`.txt`、`.pdf`、`.doc`、`.docx`、`.xlsx`,上传文件名只展示,落盘使用随机名。
|
||||
- 不新增聊天历史表,不改会会登录和用户体系。
|
||||
- 任何生产代码变更前先写并运行对应失败测试;每项任务完成后运行该任务的完整测试。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 建立后端测试夹具和文本抽取回归测试
|
||||
|
||||
**Files:**
|
||||
- Create: `digital-avatar-app/backend/tests/__init__.py`
|
||||
- Create: `digital-avatar-app/backend/tests/test_embeddings.py`
|
||||
- Create: `digital-avatar-app/backend/tests/test_chat_orchestration.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces temporary SQLite database fixtures and deterministic fake embeddings/model clients for later backend tasks.
|
||||
- Tests import `embeddings.extract_text`, `routers.knowledge._match_standard_qa`, and `routers.chat._build_prompt`/`routers.chat._resolve_reply` once those functions exist.
|
||||
|
||||
- [ ] **Step 1: Add a failing MD/TXT extraction test**
|
||||
|
||||
Create two temporary files with UTF-8 Chinese text and assert `extract_text(path, '.md')` and `extract_text(path, '.txt')` return the source text. Also assert an unsupported extension raises `ValueError` rather than silently returning an empty string.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the expected failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yqq/Works/AiProject/huihuiSquare/digital-avatar-app/backend
|
||||
python3 -m unittest tests.test_embeddings -v
|
||||
```
|
||||
|
||||
Expected: the MD/TXT cases fail because `extract_text` currently only handles the existing binary formats.
|
||||
|
||||
- [ ] **Step 3: Add failing orchestration tests**
|
||||
|
||||
Cover these exact behaviors with an in-memory `Avatar`/`QAPair` setup and mocked embedding/model calls:
|
||||
|
||||
```python
|
||||
def test_enabled_qa_wins_without_calling_model():
|
||||
result = _resolve_reply(db, avatar, " 公司地址? ", [], model_client=fake_model)
|
||||
self.assertEqual(result["source"], "qa")
|
||||
self.assertEqual(result["answer"], "标准地址")
|
||||
fake_model.assert_not_called()
|
||||
|
||||
def test_knowledge_context_is_sent_to_qwen_after_qa_miss():
|
||||
result = _resolve_reply(db, avatar, "退款规则", [], search_fn=lambda *_: [knowledge_hit], model_client=fake_model)
|
||||
self.assertEqual(result["source"], "knowledge")
|
||||
self.assertIn("知识库内容", fake_model.call_args.kwargs["messages"][-1]["content"])
|
||||
|
||||
def test_chat_rejects_avatar_owned_by_another_user():
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
_require_owned_avatar(db, avatar.id, other_user_token)
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the focused test and verify it fails for missing behavior**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.test_chat_orchestration -v
|
||||
```
|
||||
|
||||
Expected: import/behavior failures identify the missing helper and endpoint orchestration, not test syntax errors.
|
||||
|
||||
- [ ] **Step 5: Commit the test scaffolding**
|
||||
|
||||
```bash
|
||||
git add digital-avatar-app/backend/tests
|
||||
git commit -m "test: cover avatar chat priority and text extraction"
|
||||
```
|
||||
|
||||
### Task 2: Add MD/TXT extraction and secure knowledge operations
|
||||
|
||||
**Files:**
|
||||
- Modify: `digital-avatar-app/backend/embeddings.py`
|
||||
- Modify: `digital-avatar-app/backend/routers/knowledge.py`
|
||||
- Modify: `digital-avatar-app/backend/routers/avatars.py`
|
||||
- Test: `digital-avatar-app/backend/tests/test_embeddings.py`
|
||||
|
||||
**Interfaces:**
|
||||
- `embeddings.extract_text(path, ext)` accepts `.md` and `.txt` and decodes text with UTF-8 replacement handling.
|
||||
- `knowledge._require_owned_avatar(db, avatar_id, authorization)` returns the owned `Avatar` or raises 401/403.
|
||||
- Existing docs/QA/search handlers use the ownership helper before reading or mutating data.
|
||||
|
||||
- [ ] **Step 1: Implement the smallest extraction change**
|
||||
|
||||
Add `.md` and `.txt` to the text branch of `extract_text`; use `open(path, 'r', encoding='utf-8', errors='replace')`. Update the allowed extension set and client-facing error text to list MD/TXT.
|
||||
|
||||
- [ ] **Step 2: Run the extraction test to green**
|
||||
|
||||
Run `python3 -m unittest tests.test_embeddings -v`; all extraction tests must pass.
|
||||
|
||||
- [ ] **Step 3: Add ownership resolution without changing public response shapes**
|
||||
|
||||
Reuse the existing token lookup pattern from `avatars.py`. For no token return `HTTPException(status_code=401, detail='未登录')`; for a valid token whose `huihui_user_id` does not equal `avatar.owner_id`, return 403. Keep the current seed/empty-owner behavior only for explicitly unowned setup records, not for authenticated user data.
|
||||
|
||||
- [ ] **Step 4: Apply the helper to documents, search, and QA routes**
|
||||
|
||||
Every route under `/avatar/{avatar_id}/knowledge/...` must call the helper before querying. Upload must also enforce a 10 MB request file limit based on bytes read and return `文件不能超过 10MB` before vectorization.
|
||||
|
||||
- [ ] **Step 5: Run backend compile and focused tests**
|
||||
|
||||
```bash
|
||||
cd /Users/yqq/Works/AiProject/huihuiSquare/digital-avatar-app/backend
|
||||
python3 -m unittest tests.test_embeddings tests.test_chat_orchestration -v
|
||||
python3 -m py_compile main.py database.py models.py embeddings.py routers/*.py
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit the backend document/security changes**
|
||||
|
||||
```bash
|
||||
git add digital-avatar-app/backend/embeddings.py digital-avatar-app/backend/routers/knowledge.py digital-avatar-app/backend/routers/avatars.py digital-avatar-app/backend/tests
|
||||
git commit -m "feat: support text knowledge files and protect avatar knowledge"
|
||||
```
|
||||
|
||||
### Task 3: Implement QA-first Qwen chat orchestration
|
||||
|
||||
**Files:**
|
||||
- Create: `digital-avatar-app/backend/routers/chat.py`
|
||||
- Modify: `digital-avatar-app/backend/main.py`
|
||||
- Modify: `digital-avatar-app/backend/requirements.txt`
|
||||
- Modify: `digital-avatar-app/backend/tests/test_chat_orchestration.py`
|
||||
|
||||
**Interfaces:**
|
||||
- `POST /api/avatar/{avatar_id}/chat` accepts `{message: string, history?: [{role, content}]}`.
|
||||
- Success response is `{answer, source, references}` where `source` is `qa`, `knowledge`, or `qwen`.
|
||||
- `routers.chat._match_standard_qa(question, qa_pairs)` returns the winning enabled QA or `None`.
|
||||
- `routers.chat._build_prompt(avatar, history, question, knowledge_hits)` returns system/user messages with no secrets.
|
||||
|
||||
- [ ] **Step 1: Make the QA-first tests fail for the current backend**
|
||||
|
||||
Run the orchestration tests from Task 1 and confirm no chat router/helper currently exists.
|
||||
|
||||
- [ ] **Step 2: Implement deterministic QA matching**
|
||||
|
||||
Normalize Unicode whitespace, case, and Chinese/ASCII punctuation. Check exact normalized questions first; only then use `difflib.SequenceMatcher` against enabled questions and accept a similarity of at least `0.86`. Disabled QAs never match.
|
||||
|
||||
- [ ] **Step 3: Implement knowledge context assembly**
|
||||
|
||||
Call the existing vector search logic with top five hits. Include only non-empty snippets and filenames in the prompt. Preserve hit metadata as `references` and use `source='knowledge'` whenever at least one document hit was supplied to Qwen.
|
||||
|
||||
- [ ] **Step 4: Implement the OpenAI-compatible Qwen client**
|
||||
|
||||
Read:
|
||||
|
||||
```python
|
||||
CHAT_API_URL = os.getenv("CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
CHAT_API_KEY = os.getenv("CHAT_API_KEY", "")
|
||||
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen-plus")
|
||||
```
|
||||
|
||||
POST to `CHAT_API_URL.rstrip('/') + '/chat/completions'` with `Authorization: Bearer ...`, timeout 30 seconds, the configured model, bounded recent history, and the generated system prompt. Handle non-2xx responses, malformed JSON, and empty choices with user-safe errors.
|
||||
|
||||
- [ ] **Step 5: Map Avatar.config into the prompt**
|
||||
|
||||
Use defaults matching `AvatarCreate.vue`: professional, creativity 50, rigor 50, humor 30, medium, empty system prompt. Convert creativity to `temperature = 0.2 + creativity / 100 * 0.6`; describe the other fields in Chinese instructions. Clamp history to the last 10 messages and each content field to 4,000 characters.
|
||||
|
||||
- [ ] **Step 6: Register the router and run all backend tests**
|
||||
|
||||
Include `routers.chat.router` in `main.py`, then run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
python3 -m py_compile main.py database.py models.py embeddings.py routers/*.py
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit the chat endpoint**
|
||||
|
||||
```bash
|
||||
git add digital-avatar-app/backend/routers/chat.py digital-avatar-app/backend/main.py digital-avatar-app/backend/requirements.txt digital-avatar-app/backend/tests
|
||||
git commit -m "feat: add QA-first avatar chat with Qwen fallback"
|
||||
```
|
||||
|
||||
### Task 4: Add frontend API, avatar editing controls, and chat route
|
||||
|
||||
**Files:**
|
||||
- Modify: `digital-avatar-app/src/api/index.ts`
|
||||
- Modify: `digital-avatar-app/src/utils/avatar-page-data.js`
|
||||
- Modify: `digital-avatar-app/src/views/AvatarEdit.vue`
|
||||
- Modify: `digital-avatar-app/src/views/AvatarManage.vue`
|
||||
- Create: `digital-avatar-app/src/views/AvatarChat.vue`
|
||||
- Modify: `digital-avatar-app/src/router/index.ts`
|
||||
- Test: `digital-avatar-app/scripts/avatar-page-data.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- `sendAvatarChat(avatarId, payload)` calls `POST /avatar/${avatarId}/chat`.
|
||||
- `normalizeAvatarEditForm` returns all six config values with defaults.
|
||||
- `buildAvatarUpdatePayload` includes the nested `config` object.
|
||||
- `/avatar/chat/:id` renders a usable conversation without requiring a second login flow.
|
||||
|
||||
- [ ] **Step 1: Extend the frontend helper test first**
|
||||
|
||||
Add assertions for missing config defaults, payload preservation, chat request shape, and source labels `qa`, `knowledge`, `qwen`; run the test and verify the new assertions fail.
|
||||
|
||||
- [ ] **Step 2: Add chat API types and helper functions**
|
||||
|
||||
Add `ChatMessage`, `ChatResponse`, and `sendAvatarChat`. Keep Axios response unwrapping consistent with the existing interceptor; do not read an extra `.data` layer in the view.
|
||||
|
||||
- [ ] **Step 3: Extend AvatarEdit controls and payload**
|
||||
|
||||
Add the same style chips, range sliders, response length choices, and system prompt textarea used by `AvatarCreate.vue`. Initialize through `normalizeAvatarEditForm`; save through `buildAvatarUpdatePayload` so existing name/description/photo/status fields remain intact.
|
||||
|
||||
- [ ] **Step 4: Add the chat route and page**
|
||||
|
||||
Create a mobile-first page with a top back button, avatar identity header, scrollable message list, source/reference labels, textarea input, send button, empty-state starter prompts, and error retry. On send, append the user message, send the last 10 messages, append the assistant response, and keep the input focused only when the request succeeds.
|
||||
|
||||
- [ ] **Step 5: Wire management entry points**
|
||||
|
||||
Make the primary digital-avatar card/action open `/avatar/chat/${avatar.id}`; keep edit, knowledge, and delete actions separate so clicking chat does not trigger an unrelated action.
|
||||
|
||||
- [ ] **Step 6: Run frontend helper test and production build**
|
||||
|
||||
```bash
|
||||
cd /Users/yqq/Works/AiProject/huihuiSquare/digital-avatar-app
|
||||
node scripts/avatar-page-data.test.mjs
|
||||
./node_modules/.bin/vite build
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit the frontend chat/edit changes**
|
||||
|
||||
```bash
|
||||
git add digital-avatar-app/src/api/index.ts digital-avatar-app/src/utils/avatar-page-data.js digital-avatar-app/src/views/AvatarEdit.vue digital-avatar-app/src/views/AvatarManage.vue digital-avatar-app/src/views/AvatarChat.vue digital-avatar-app/src/router/index.ts digital-avatar-app/scripts/avatar-page-data.test.mjs
|
||||
git commit -m "feat: add avatar chat and editable personality settings"
|
||||
```
|
||||
|
||||
### Task 5: Rebuild knowledge management as scrollable tabbed tables
|
||||
|
||||
**Files:**
|
||||
- Modify: `digital-avatar-app/src/views/KnowledgeManage.vue`
|
||||
- Modify: `digital-avatar-app/src/views/QaPairEdit.vue`
|
||||
- Modify: `digital-avatar-app/src/api/index.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Documents and QAs remain loaded through the existing APIs and are independently rendered.
|
||||
- The page exposes `activeTab` values `docs` and `qa` and keeps data loaded when switching tabs.
|
||||
- Upload accept string and displayed help text include `.md` and `.txt`.
|
||||
|
||||
- [ ] **Step 1: Add a failing UI contract smoke test**
|
||||
|
||||
Add a small source-level Node assertion script or extend the existing helper test to assert the component contains the two tab labels, `activeTab`, `.md`, and `.txt`; run it before changing the component and confirm failure.
|
||||
|
||||
- [ ] **Step 2: Implement tab state and document panel**
|
||||
|
||||
Keep the upload zone and search controls in the document tab. Replace document cards with a semantic table: filename/type, size, vectorization status/chunks, created time, and delete action. Use a `.table-scroll` wrapper with a bounded `max-height` and `overflow-y: auto`.
|
||||
|
||||
- [ ] **Step 3: Implement the QA table panel**
|
||||
|
||||
Render question, answer summary, enabled switch/status, updated time, edit and delete actions. Keep the add button routing to `QaPairEdit.vue`. Do not fetch from the API again on every tab switch.
|
||||
|
||||
- [ ] **Step 4: Fix page scrolling and responsive layout**
|
||||
|
||||
Set the page to `min-height: 100dvh`, `overflow-x: hidden`, and normal vertical flow. Make the tab/table wrappers responsive so desktop uses side-by-side table columns where space permits and mobile stacks the active table with horizontal table overflow only when required.
|
||||
|
||||
- [ ] **Step 5: Run frontend build and inspect the generated CSS/JS markers**
|
||||
|
||||
Run `./node_modules/.bin/vite build` and assert the generated bundle contains the new tab labels and MD/TXT accept values.
|
||||
|
||||
- [ ] **Step 6: Commit the knowledge UI changes**
|
||||
|
||||
```bash
|
||||
git add digital-avatar-app/src/views/KnowledgeManage.vue digital-avatar-app/src/views/QaPairEdit.vue digital-avatar-app/src/api/index.ts digital-avatar-app/scripts
|
||||
git commit -m "feat: add text uploads and tabbed knowledge tables"
|
||||
```
|
||||
|
||||
### Task 6: Backend/API integration verification and test deployment
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/yqq/Works/AiProject/huihuiSquare/digital-avatar-app/.env` only if local test defaults need documenting; never commit secrets.
|
||||
- Modify: `/Users/yqq/Works/AiProject/huihuiSquare/digital-avatar-app/backend/.env` only through the test server deployment environment, never commit secrets.
|
||||
- Test: `digital-avatar-app/backend/tests/test_chat_orchestration.py`
|
||||
- Test: `digital-avatar-app/scripts/avatar-page-data.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Test server receives `CHAT_API_URL`, `CHAT_API_KEY`, and `CHAT_MODEL` in `/root/avatar-test/.env` without logging the key.
|
||||
- Frontend `/api` reaches the newly deployed backend router.
|
||||
|
||||
- [ ] **Step 1: Verify model configuration availability without printing secrets**
|
||||
|
||||
On `geo-server`, inspect only whether `CHAT_API_KEY` is set and print `CHAT_API_URL`/`CHAT_MODEL`. If the key is absent, stop deployment and report that the user must provide/configure it; do not fall back to a fake answer.
|
||||
|
||||
- [ ] **Step 2: Rebuild the backend image and restart only avatar-test services**
|
||||
|
||||
From `/root/avatar-test`, rebuild `avatar-test-backend`, recreate that container, and confirm `/api/health` plus an unauthenticated chat request returns 401/未登录. Preserve the existing SQLite file by taking a timestamped copy before recreation and restoring it only if the compose flow replaces the container without a volume.
|
||||
|
||||
- [ ] **Step 3: Build and deploy the frontend dist safely**
|
||||
|
||||
Run `vite build`, upload `dist` to a timestamped staging directory, back up `/root/avatar-test/run-dist`, then copy only `assets/.` and `index.html` into `avatar-test-frontend` and restart it. Do not use a broad `docker cp run-dist/. html/` replacement.
|
||||
|
||||
- [ ] **Step 4: Run live QA-first acceptance checks**
|
||||
|
||||
Using a valid test app token, create one short MD or TXT document and one enabled QA pair. Send the exact QA question and verify response `source=qa`; send a non-QA question and verify the response is from `knowledge` or `qwen` with no secret leakage. Delete the temporary QA/document records after capturing results.
|
||||
|
||||
- [ ] **Step 5: Verify frontend and backend health**
|
||||
|
||||
```bash
|
||||
curl -fsS http://192.168.1.188:8099/api/health
|
||||
curl -fsS http://192.168.1.188:8011/api/health
|
||||
```
|
||||
|
||||
Inspect the deployed `index.html` asset names and confirm the chat route, MD/TXT accept values, and tab labels are in the new bundle. Record the deployed container status and backup path.
|
||||
|
||||
- [ ] **Step 6: Final verification and handoff**
|
||||
|
||||
Run the backend unit suite, Python compile check, frontend smoke test, Vite build, `git diff --check`, and `git status --short`. Report any remaining type-check limitation separately from build status; do not claim Qwen live success unless the configured test key produced a real response.
|
||||
@@ -0,0 +1,72 @@
|
||||
# 数字分身聊天与知识库增强设计
|
||||
|
||||
## 目标
|
||||
|
||||
让用户可以在数字分身管理页进入聊天窗口,并让每次回复严格遵循“标准问答对 > 文件知识库 > OpenAI 兼容 Qwen 模型”的业务优先级;同时补齐分身人格配置、Markdown/TXT 文档支持和知识库管理页面的可用性。
|
||||
|
||||
## 已确认范围
|
||||
|
||||
- 文档格式:在现有 PDF、DOC、DOCX、XLSX 基础上增加 MD、TXT。
|
||||
- 回复顺序:启用的标准问答对优先;未命中后检索文件知识库;最终由 OpenAI 兼容接口的 Qwen 模型生成。
|
||||
- 模型配置:后端环境变量 `CHAT_API_URL`、`CHAT_API_KEY`、`CHAT_MODEL`,密钥不进入前端。
|
||||
- 分身配置:回复风格、创造力、严谨度、幽默感、回复长度、系统提示词保存到 `Avatar.config`。
|
||||
- 知识库 UI:文档知识库和标准问答对使用两个可切换的 table 面板;表体独立滚动,页面在移动端仍可上下滚动。
|
||||
|
||||
## 后端设计
|
||||
|
||||
### 文档解析
|
||||
|
||||
扩展 `backend/embeddings.py` 的文本抽取能力:`.md` 和 `.txt` 使用 UTF-8 优先、带替换错误的纯文本读取;分块、向量化和现有文档保持一致。`knowledge.py` 的允许扩展名、错误提示和文件类型注释同步更新。
|
||||
|
||||
### 聊天编排
|
||||
|
||||
新增 `POST /api/avatar/{avatar_id}/chat`,请求体包含 `message` 和可选的最近历史消息;响应包含 `answer`、`source`、可选 `references`。入口先校验 Bearer app token 对应用户拥有该分身,拒绝未登录或越权访问。
|
||||
|
||||
编排步骤:
|
||||
|
||||
1. 清理问题空白并限制长度;查询该分身启用的问答对。
|
||||
2. 对问答对问题做空白、大小写和标点归一化;先精确匹配,再使用确定性的轻量相似度匹配,达到阈值直接返回标准答案,`source=qa`,不调用模型。
|
||||
3. 未命中时复用现有向量检索,获取高相关文档片段;片段存在时加入模型上下文,`source=knowledge`。
|
||||
4. 调用 OpenAI 兼容 `/chat/completions`,使用 Qwen 配置和分身 `Avatar.config` 生成回答;没有文档片段时 `source=qwen`。
|
||||
5. Qwen 未配置、调用失败或返回空内容时返回明确的可展示错误,不返回伪造答案。
|
||||
|
||||
人格配置映射到系统提示词和模型参数:回复风格、严谨度、幽默感、回复长度进入系统提示词;创造力映射到受限的 temperature 范围。历史消息只传递最近有限条,避免请求无限增长。
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 聊天窗口
|
||||
|
||||
新增 `AvatarChat.vue` 和 `/avatar/chat/:id` 路由。管理页的分身卡片点击进入该路由。聊天页提供消息气泡、来源标记、引用文件名、发送中状态、失败重试提示和移动端固定输入区;发送按钮在空消息或请求中禁用。
|
||||
|
||||
### 分身编辑
|
||||
|
||||
在 `AvatarEdit.vue` 增加与 `AvatarCreate.vue` 对齐的六项配置控件,并通过现有 `updateAvatar` 写入 `config`。详情初始化时兼容缺失配置,使用创建页默认值;保存后重新加载分身并回到管理页。
|
||||
|
||||
### 知识库管理
|
||||
|
||||
`KnowledgeManage.vue` 顶部保留上传区和分身上下文,下面改为两个 tab:
|
||||
|
||||
- 文档知识库:上传、向量化状态、文件大小/类型、删除和检索测试。
|
||||
- 标准问答对:问题、答案摘要、启用状态、编辑、删除和添加。
|
||||
|
||||
文档和问答 table 使用固定的最大内容高度与 `overflow-y: auto`,根页面使用 `min-height: 100dvh` 和正常页面滚动;不使用会锁死移动端滚动的全局 `overflow: hidden`。
|
||||
|
||||
## 错误与安全
|
||||
|
||||
- 聊天接口必须验证用户和分身归属;知识库和问答现有接口也同步使用当前用户校验,避免通过 avatar ID 读取或修改他人数据。
|
||||
- 上传限制文件扩展名和单文件大小,文件名仅用于展示,落盘名继续使用随机 ID。
|
||||
- 模型密钥只读取后端环境变量;日志不得输出完整请求、密钥或用户 token。
|
||||
- 模型服务超时返回可理解的错误,并保留标准问答命中不依赖模型的能力。
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 后端单元测试:MD/TXT 抽取、问答精确/相似命中、问答优先级、知识片段上下文、无知识时 Qwen 调用、配置映射、未登录/越权拒绝。
|
||||
- 前端测试:编辑配置 payload、聊天请求/响应映射、知识库 tab 切换和列表解包。
|
||||
- 构建验证:`vite build`、Python 编译检查、已有 smoke tests。
|
||||
- 测试环境验收:上传 MD/TXT、添加并启用问答对、验证同一问题优先返回标准答案,再验证未命中问题进入知识库/Qwen;最后确认移动端长列表可滚动。
|
||||
|
||||
## 不在本次范围
|
||||
|
||||
- 不新增独立聊天数据库或聊天历史持久化表;本期历史仅由前端会话维护。
|
||||
- 不改会会登录接口和会会用户体系。
|
||||
- 不把根目录已有管理后台的 AI 模型配置直接耦合进数字分身测试服务;数字分身服务使用明确的 `CHAT_*` 环境变量。
|
||||
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 10m;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<span class="page-title-bar">{{ currentTitle }}</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<!-- 右上角消息通知组件暂时注释 -->
|
||||
<div class="online-badge">
|
||||
<span class="pulse"></span>
|
||||
<span>{{ onlineUsers }} 在线</span>
|
||||
|
||||
@@ -100,7 +100,7 @@ const filters = reactive({ keyword: '', interact_type: null, status: null, start
|
||||
|
||||
const typeMap = { comment: '评论', reply: '回复', like: '点赞', collect: '收藏', forward: '转发' }
|
||||
const typeTagType = { comment: '', reply: 'info', like: 'success', collect: 'warning', forward: 'danger' }
|
||||
const statusTagType = { 0: 'info', 1: 'success', 2: 'danger' }
|
||||
const statusTagType = { 0: 'info', 1: 'success', 2: 'danger', 3: 'warning' }
|
||||
|
||||
function onDateChange(v) {
|
||||
filters.start_date = v?.[0] || ''; filters.end_date = v?.[1] || ''
|
||||
@@ -171,7 +171,7 @@ async function handleCancel(row) {
|
||||
const res = await cancelInteraction(row.id)
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
ElMessage.success('取消成功')
|
||||
await loadInteractions()
|
||||
await load()
|
||||
} else {
|
||||
ElMessage.error(res.message || '取消失败')
|
||||
}
|
||||
|
||||
29
overview-avatar-default-2026-07-21.md
Normal file
29
overview-avatar-default-2026-07-21.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 形象风格默认使用当前用户头像
|
||||
|
||||
## 已完成
|
||||
- 在创建数字分身的 **Step 2「形象风格」** 中,**「选择形象」默认选中当前用户的会会头像**。
|
||||
- 新增「使用我的头像」选项卡片;点击后沿用 `avatarUrl` 作为分身形象。
|
||||
- 表情形象选项仍可切换;切换为表情后自动清空 `photoUrl`,最终创建的分身不会误用用户头像。
|
||||
|
||||
## 关键改动
|
||||
- `digital-avatar-app/src/views/AvatarCreate.vue`
|
||||
- 新增 `userAvatarUrl` 计算属性。
|
||||
- 新增 `usePhoto` 响应式状态;初始化时若用户有头像则默认启用。
|
||||
- Step 2 新增「使用我的头像」卡片 + 表情网格。
|
||||
- 确认页与提交按 `usePhoto` 决定是否使用 `photoUrl`。
|
||||
|
||||
## 构建与部署
|
||||
- 本地 `./node_modules/.bin/vite build` 通过(绕过已知的 vue-tsc 兼容问题)。
|
||||
- 部署路径:`dist/` → `geo-server:/root/avatar-test/run-dist/` → `docker cp avatar-test-frontend:/usr/share/nginx/html/`。
|
||||
- 测试环境地址:`http://192.168.1.188:8099`。
|
||||
- 已验证线上 `AvatarCreate-Bw_iuiqc.js` 包含新的 `usePhoto` 逻辑与"使用我的头像"文案,首页 HTTP 200。
|
||||
|
||||
## 设计稿
|
||||
- Ardot 设计稿「会会数字分身-形象风格(默认头像)」:
|
||||
- 文件地址:https://ardot.tencent.com/file/706264845036089
|
||||
- 沿用 Glass Sunset Warm 色系(`#FFF6EC` 背景、`#FF8C42` 强调色、`#4A2511` 文字)。
|
||||
- 展示 Step 2 默认选中「使用我的头像」、表情备选网格、回复风格分段选择。
|
||||
|
||||
## 后续注意
|
||||
- 当前仅在 Web H5 实现;uniapp 壳内若从 native 注入 `avatarUrl`,`avatarStore.userProfile` 已优先覆盖,逻辑自动生效。
|
||||
- 表情选项和头像选项为互斥;用户可在创建流程中随时切换。
|
||||
75
overview-avatar-user-isolation-2026-07-21.md
Normal file
75
overview-avatar-user-isolation-2026-07-21.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 会会数字分身 · 用户资料与分身隔离落地说明
|
||||
|
||||
## 问题
|
||||
用户登录成功后,发现页面显示"已有数字分身",且没有看到当前登录账号的头像/昵称;同时希望在创建数字分身时直接沿用当前会会账号的名称和头像。
|
||||
|
||||
## 根因
|
||||
1. **后端 `GET /api/avatar` 全局返回所有分身**,没有按用户隔离;且 `backend/main.py` 的 `seed()` 在数据库为空时会插入一个名为"我的数字分身"的种子记录。任何新登录用户都会看到它。
|
||||
2. 用户头像/昵称虽然已经在登录响应中返回并落库存入 `users` 表,但**仪表盘没有展示**。
|
||||
3. `AvatarCreate.vue` 只预填了昵称到 `displayName`,没有 `photoUrl` 字段,也没有头像预览。
|
||||
|
||||
## 改动
|
||||
|
||||
### 后端:分身按用户隔离
|
||||
- `backend/models.py`
|
||||
- `Avatar` 新增 `owner_id` 字段(会会 `huihui_user_id`),索引,默认空字符串。
|
||||
- `to_dict()` 暴露 `ownerId`。
|
||||
- `backend/database.py`
|
||||
- 在 `_try_add_columns` 中加入 `("avatars", "owner_id", "VARCHAR DEFAULT ''")`,容器启动时自动给已有 SQLite 表加列。
|
||||
- `backend/routers/avatars.py`
|
||||
- 新增 `_resolve_user()` 从 `Authorization: Bearer <app_token>` 解析当前用户。
|
||||
- `GET /api/avatar` 仅返回 `owner_id == user.huihui_user_id` 的分身;无/无效 token 返回空列表。
|
||||
- `POST /api/avatar` 创建时写入当前用户的 `owner_id`。
|
||||
|
||||
### 前端:仪表盘展示用户资料头
|
||||
- `src/views/AvatarManage.vue`
|
||||
- 引入 `useUserStore`。
|
||||
- 顶部新增用户资料头:圆形头像(`userStore.user.avatarUrl`,无则 emoji 回退)+ 昵称 + 登录状态/手机号。
|
||||
- 分身卡片优先显示真实头像 `photoUrl`,无则回退 emoji。
|
||||
|
||||
### 前端:创建分身预填名称与头像
|
||||
- `src/views/AvatarCreate.vue`
|
||||
- 表单新增 `photoUrl`。
|
||||
- `onMounted` 从 `avatarStore.userProfile || userStore.user` 预填 `name`、`displayName`、`photoUrl`。
|
||||
- Step1 新增"分身头像"预览区与"已沿用你的会会头像"提示。
|
||||
- 确认页显示用户头像(或 emoji 回退)。
|
||||
- 提交时携带 `photoUrl`。
|
||||
- 引导页"已有数字分身?"文案改为 `v-if="avatarStore.avatars.length > 0"`,避免新用户被误导。
|
||||
|
||||
### Ardot 视觉稿
|
||||
- 新建「会会数字分身-仪表盘与创建页」(`fileId: 706241841026616`)。
|
||||
- 包含两屏:
|
||||
1. 数字分身管理仪表盘(状态栏、橙头、用户资料头、Token 余额、分身卡片、工具入口)。
|
||||
2. 创建数字分身 Step1(头像预览、预填名称/显示名称、描述、下一步)。
|
||||
- 沿用 Glass Sunset Warm 品牌色系(`#FFF6EC` 背景、`#FF8C42` 主色、`#4A2511` 文字)。
|
||||
|
||||
## 部署
|
||||
- 前端:`./node_modules/.bin/vite build` → `rsync dist/` → `docker cp` 进 `avatar-test-frontend` → restart。
|
||||
- 后端:`rsync models.py / database.py / routers/avatars.py` → `docker cp` 进 `avatar-test-backend` → restart;启动时自动迁移 `owner_id` 列。
|
||||
|
||||
## 验证结果(容器内实测)
|
||||
| 检查项 | 结果 |
|
||||
|---|---|
|
||||
| `owner_id` 列已迁移 | ✅ |
|
||||
| 种子分身 `owner_id=''` | ✅ |
|
||||
| 无 auth `GET /api/avatar` | `{data:[],total:0}` ✅ |
|
||||
| 登录用户 `GET /api/avatar` | `{data:[],total:0}`(种子隐藏)✅ |
|
||||
| 创建分身写入 `ownerId` + `photoUrl` | ✅ |
|
||||
| 创建后本人列表只含 1 条 | ✅ |
|
||||
| 删除后列表回归空 | ✅ |
|
||||
|
||||
## 用户现在体验
|
||||
1. 登录后进入 `/#/`(创建引导)或 `/#/avatar/manage`(仪表盘),**顶部会显示当前会会账号的头像和昵称**。
|
||||
2. 新用户首次登录时,**不会看到任何"已有"分身**(列表为空)。
|
||||
3. 点"立即创建一个"进入创建流程,**名称和头像已经自动预填**了当前用户的会会昵称和头像。
|
||||
4. 创建成功后,仪表盘只显示用户自己创建的分身。
|
||||
|
||||
## 访问地址
|
||||
测试环境(同网段/隧道可达):
|
||||
- 创建引导页:`http://192.168.1.188:8099/#/`
|
||||
- 仪表盘:`http://192.168.1.188:8099/#/avatar/manage`
|
||||
- 登录页:`http://192.168.1.188:8099/#/login/sms`
|
||||
|
||||
## 后续可选
|
||||
- 生产环境切到 `99hui.com` 后可真正下发短信;当前 `fat-open` 预发环境建议继续用账号密码登录。
|
||||
- 可在创建页支持用户上传/裁剪自定义头像,替代目前直接沿用会会头像的方案。
|
||||
40
overview-deploy-2026-07-17.md
Normal file
40
overview-deploy-2026-07-17.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 数字分身测试环境部署 + 修复概览(2026-07-17)
|
||||
|
||||
## 本次动作:把上一轮改动真正发到测试环境
|
||||
测试环境:`geo-server`(192.168.1.188) 上 `/root/avatar-test`,docker-compose 跑
|
||||
`avatar-backend`(:8011) + `avatar-frontend`(:8099)。
|
||||
|
||||
访问地址:**http://192.168.1.188:8099/**(短信登录页:`/#/login/sms`)
|
||||
|
||||
已上线内容(上一轮你质疑的"创建覆盖/删除/去掉会会资料/真实短信"相关代码):
|
||||
- `AvatarManage.vue`:去掉"我的会会资料",改为全部分身列表,每张带删除+二次确认
|
||||
- `store/avatar.ts`:新增 `removeAvatar`
|
||||
- `backend/routers/avatars.py`:删除级联清理(知识库/切片/问答对/授权)
|
||||
- `backend/routers/huihui_auth.py`:真实生产调用优先,`DEV_MOCK` 仅兜底
|
||||
|
||||
## 端到端验证(已通过)
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| 后端 health | ✅ |
|
||||
| 分身列表 `GET /api/avatar` | ✅ 返回已 seed 分身 |
|
||||
| 短信发码(开发态) | ✅ 回显 `devCode` |
|
||||
| 短信登录→`/me` | ✅ 落库 dev 用户 + 会话 |
|
||||
| 删除级联 | ✅ 创建→删除→GET 404 |
|
||||
| 前端页面 `:8099` | ✅ 稳定 Up,`<title>会会数字分身</title>` |
|
||||
|
||||
## 部署中踩的两个坑(已修复,记进项目记忆)
|
||||
1. **环境变量被 rsync 覆盖**:本机 compose 不含 env,把远端原 `HUIHUI_DEV_MOCK`/`EMBEDDING_*` 丢了。
|
||||
修复:从运行容器 `docker inspect` 导出 4 个变量到远端 `.env`,compose 改用 `env_file` 引用;`.env` 加进 `.dockerignore`;本机同步加 `env_file`+`.gitignore` 防再丢。
|
||||
2. **前端容器反复 Restarting**:nginx 1.31.3 写 pid 文件被测试服务器 Docker 的 **seccomp 拦截 `pwrite`** 致命退出(老版 nginx 用 `write()` 且只告警,故之前 46h 正常)。
|
||||
修复:前端 `Dockerfile` 锁定 `nginx:1.28-alpine`(远端已缓存,无需联网);`nginx.conf` 改为完整主配置覆盖 `/etc/nginx/nginx.conf`。
|
||||
|
||||
## 关于"参考会会接口文档"(真实短信仍待你提供)
|
||||
- 仓库 `docs/production-interface-inventory.md` **只文档化了 `/open/login/token`(密码登录),没有短信端点**。
|
||||
- 已实现的真实生产调用:短信**登录**复用文档化的 `/open/login/token`,仅 `loginType/grantType=sms`(与会会开放平台一致范式);**发送验证码**端点走可配置 `HUIHUI_SMS_SEND_PATH`(默认 `/open/login/sms/send` 在生产返回 404)。
|
||||
- 仍阻塞:会会**真实短信发送端点路径**(如 `/open/sms/send` 等)+ 开放平台凭证(appId/accessId/accessSecret/clientCode)未到位。你提供后,关掉 `HUIHUI_DEV_MOCK`、写进 compose env、重建后端即可真接通会会生产库并同步用户资料。
|
||||
|
||||
## 改动文件
|
||||
- `digital-avatar-app/Dockerfile`(锁 nginx 1.28-alpine)
|
||||
- `digital-avatar-app/nginx.conf`(完整主配置,覆盖默认 nginx.conf)
|
||||
- `digital-avatar-app/docker-compose.yml`(backend 加 `env_file: - .env`)
|
||||
- `digital-avatar-app/.gitignore`、`digital-avatar-app/backend/.dockerignore`(忽略 `.env`)
|
||||
31
overview-huihui-auth-2026-07-21.md
Normal file
31
overview-huihui-auth-2026-07-21.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 会会用户体系打通 · 部署与验证概览
|
||||
|
||||
## 完成内容
|
||||
将 `digital-avatar-app` 的短信验证码登录从「DEV_MOCK 模拟」切换为**真实会会开放平台对接**,并部署到测试环境 `geo-server:/root/avatar-test`。
|
||||
|
||||
## 关键改动
|
||||
1. **`backend/routers/huihui_auth.py` 重写**
|
||||
- 真实端点:发码 `POST /open/mobile/sms/code`(query 参数)、登录 `POST /open/login/token`(formData,`loginType=code`)。
|
||||
- 签名范式与会会官方 `sign(1).js` 逐字节一致:公共字段 `appId/accessId/timestamp(12h hh)/signType(MD5)/signVersion(1.0)/accessSecret/nonce` → 过滤空值 → 字典序 → `k=v&` → 末尾 `accessSecret=secretKey` → MD5/SHA256 大写。
|
||||
- 登录成功后建/链本地 `users` 表(按会会 userId 唯一),签发本系统 `app_token` 会话。
|
||||
2. **真实凭证入环境**
|
||||
- `geo-server:/root/avatar-test/.env`(及本地 `digital-avatar-app/.env`):`HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET`,`HUIHUI_DEV_MOCK=false`。
|
||||
- `.env` 已被 `.gitignore` 与 `backend/.dockerignore` 排除,不会入库/入镜像。
|
||||
3. **时区 bug 修复(关键)**
|
||||
- 容器默认 UTC,会会网关按**北京时间**校验时间戳 → 签名被拒("签名验证失败")。
|
||||
- `_get_timestamp()` 改为显式 `timezone(timedelta(hours=8))`,与容器系统时区解耦(不用 `ZoneInfo`,slim 镜像缺 tzdata)。
|
||||
|
||||
## 验证结果(测试环境)
|
||||
| 接口 | 结果 |
|
||||
|---|---|
|
||||
| `POST /api/huihui/sms/send`(13800000001) | 网关 `code=200 success, sent=true` ✅ |
|
||||
| `POST /api/huihui/sms/login`(假验证码) | 网关 `code=10000 验证码不正确`(签名已接受)✅ |
|
||||
| `GET /api/huihui/me`(无 token) | `401 未登录`(路由/鉴权门正常)✅ |
|
||||
| 前端 `:8099` → 后端 `:8000` | nginx `/api` 代理正常 ✅ |
|
||||
|
||||
## 待用户操作
|
||||
在浏览器打开 `http://192.168.1.188:8099` → 短信登录页 → 输入**本人会会绑定手机号** → 获取真实验证码 → 登录。后端将调会会网关校验并写入 `users` 表,前端拿到 `app_token` + 会会资料后跳转。这是唯一未自动化的步骤(需真实手机号+真实验证码)。
|
||||
|
||||
## 排查笔记
|
||||
- 测试服务器宿主 `:8000` 另跑 `ai-virtual-news-backend`;容器映射 `8011→8000`,本地冒烟测试必须打 `:8011`。
|
||||
- 部署前用独立脚本(managed venv + httpx)直连真实网关验证签名,避免盲部署。
|
||||
22
overview-login-page-2026-07-21.md
Normal file
22
overview-login-page-2026-07-21.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 会会数字分身登录页重设计 — 完成概览
|
||||
|
||||
## 做了什么
|
||||
- 在 Ardot 完成了「会会数字分身-登录页」高保真设计稿。
|
||||
- 按设计稿重写了前端页面 `digital-avatar-app/src/views/SmsLogin.vue`。
|
||||
- Vite 生产构建验证通过。
|
||||
|
||||
## 关键决策
|
||||
- 视觉风格:Glass Sunset Warm 品牌色(背景 `#FFF6EC`、主色 `#FF8C42`、文字 `#4A2511`)+ Vision OS Aurora 玻璃拟态。
|
||||
- 布局:移动端全屏,单栏内容包裹器,无侧边距,输入卡片和按钮通栏。
|
||||
- 品牌图形:内联渐变机器人 SVG,替代原有 emoji,更具数字分身识别度。
|
||||
- CTA 文案改为「登录 / 创建分身」,直接传达未注册手机号自动建号的业务规则。
|
||||
- 保留原组件全部业务逻辑:手机号/验证码校验、60s 倒计时、演示模式 banner、会会登录 API、登录成功后同步用户资料并跳转。
|
||||
|
||||
## 产物位置
|
||||
- Ardot 设计稿:`https://ardot.tencent.com/file/706196382431752`
|
||||
- 前端页面:`digital-avatar-app/src/views/SmsLogin.vue`
|
||||
- 设计截图:`/Users/yqq/Works/AiProject/huihuiSquare/.workbuddy/cache/ardot-shots/screenshot(2_1)-20260721_121632.png`
|
||||
|
||||
## 待跟进
|
||||
- 需要用户在测试环境用真实会会绑定手机号走完整登录链路(发真实短信 → 输入真码 → 进入首页),验证 UI 与后端新对接的会会用户体系端到端可用。
|
||||
- 可进一步用 HBuilderX 把前端构建产物更新到 uniapp 壳工程中,做真机视觉走查。
|
||||
42
overview-nickname-prefill-fix-2026-07-21.md
Normal file
42
overview-nickname-prefill-fix-2026-07-21.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 创建分身未预填登录昵称 — 根因与修复
|
||||
|
||||
## 现象
|
||||
登录成功后,进入「创建数字分身」,分身名称 / 显示名称没有默认填入当前登录用户的昵称;
|
||||
而仪表盘(数字分身管理)却正常显示该昵称。两者读数不一致。
|
||||
|
||||
## 根因(前端取值优先级 bug)
|
||||
`AvatarCreate.vue` 的 `onMounted` 原逻辑:
|
||||
```js
|
||||
const profile = avatarStore.userProfile || userStore.user
|
||||
const nick = profile?.nickname
|
||||
```
|
||||
- `avatarStore.userProfile` 是一个 **truthy 对象**(即使昵称为空字符串),会抢在真正含昵称的
|
||||
`userStore.user`(登录会话 + localStorage 持久化)之前被选中。
|
||||
- `src/main.ts` 在应用启动时执行 `setNativeProfile({ nickname: userStore.user.nickname || '' })`:
|
||||
当 localStorage 里是旧会话、昵称为空时,会把一个「空昵称对象」写进 `userProfile`。
|
||||
- 于是 `profile.nickname === ''`,创建页不预填;仪表盘直接读 `userStore.user` 所以照常显示。
|
||||
|
||||
DB 实证:`avatar-test` 容器内 `/app/avatar.db` 的 `users` 表确有真实昵称(如 `猜猜我是谁`)与真实头像
|
||||
URL —— 数据本身没问题,纯前端取值优先级问题。
|
||||
|
||||
## 修复
|
||||
文件:`digital-avatar-app/src/views/AvatarCreate.vue`
|
||||
1. `onMounted` 改为 **优先 `userStore.user`,`avatarStore.userProfile` 仅作兜底**:
|
||||
```js
|
||||
const real = userStore.user
|
||||
const native = avatarStore.userProfile
|
||||
const nick = real?.nickname || native?.nickname || ''
|
||||
const avatar = real?.avatarUrl || native?.avatarUrl || ''
|
||||
```
|
||||
2. `userAvatarUrl` 计算属性同样改为优先 `userStore.user.avatarUrl`。
|
||||
3. 兜底:本地两者皆空且已登录时,调用 `getCurrentUser()`(`GET /huihui/me`,读 DB 权威值)补拉真实昵称/头像。
|
||||
|
||||
## 部署与验证
|
||||
- `./node_modules/.bin/vite build` → 新 chunk `AvatarCreate-CNMENOcc.js`
|
||||
- 部署到测试环境 `geo-server`:`rsync dist/` → `docker cp` 进 `avatar-test-frontend:/usr/share/nginx/html/`
|
||||
- 容器内清理旧 AvatarCreate 孤儿 chunk(`Bw_iuiqc.js` 等多份历史残留)
|
||||
- 验证:首页 HTTP 200;线上 chunk 含 `displayName` 预填逻辑
|
||||
|
||||
## 经验
|
||||
前端「当前用户资料」应统一以 `userStore.user`(登录会话、localStorage 持久化)为权威源;
|
||||
`avatarStore.userProfile`(原生壳注入 / 启动桥接)是可空、可被空值覆盖的派生源,绝不能放在 `||` 左侧优先。
|
||||
45
overview-password-login-2026-07-21.md
Normal file
45
overview-password-login-2026-07-21.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 会会数字分身 · 短信收不到 + 账号密码登录
|
||||
|
||||
## 1. "收不到短信验证码" 是否接通?
|
||||
**已接通,但预发环境不下发真实短信。**
|
||||
|
||||
实测证据(直连会会网关):
|
||||
- 后端日志:用户的发码/登录请求已到达并 `200 OK`。
|
||||
- 会会原始响应:`{code:0, message:"验证码发送成功", ...}`
|
||||
- 签名算法、`appId/accessId/secret` 均正确,时间戳走 24h `HH`(避免"签名验证失败")。
|
||||
|
||||
**为什么收不到**:当前对接的是 `fat-open.99hui.com`(会会**预发/测试网关**)。
|
||||
它接收请求并返回"发送成功",但**不实际把短信下发到真实手机**(测试环境模拟发送)。
|
||||
这是环境限制,不是代码 bug。要真实收到短信需切到生产网关 `99hui.com/api/usercenter`(同套开放平台凭证通常通用,但生产是否真连短信通道需确认/由会会开启)。
|
||||
|
||||
## 2. 新增「账号密码登录」(默认方式)
|
||||
既然短信在测试环境不可达,新增可靠的账号密码登录,并设为默认标签。
|
||||
|
||||
### 后端 `backend/routers/huihui_auth.py`
|
||||
- 新增 `pwd_login` → `POST /api/huihui/pwd/login`
|
||||
- 调会会 `/open/login/token`,参数与会会开放平台一致:
|
||||
`username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false`
|
||||
- 复用 `_issue_session` 建/链本地 `users` 表并签发 `app_token`
|
||||
- 账号非 11 位手机号时不覆盖已有 `user.phone`
|
||||
|
||||
### 前端
|
||||
- `src/api/index.ts`:新增 `loginByPassword(account, password)` → `/huihui/pwd/login`
|
||||
- `src/store/user.ts`:新增 `loginByPwd` action
|
||||
- `src/views/SmsLogin.vue`:**分段切换** UI
|
||||
- 默认标签 **密码登录**(账号/手机号 + 密码)
|
||||
- 次选标签 **短信登录**(原手机号+验证码)
|
||||
|
||||
### 部署
|
||||
`vite build` → `rsync dist/` → `docker cp` 进 `avatar-test-frontend` 容器 → `docker restart`。
|
||||
|
||||
### 验证
|
||||
- 线上 SmsLogin chunk 含「密码登录」、api bundle 含「huihui/pwd/login」✅
|
||||
- 实时 `POST /huihui/pwd/login`(假密码)→ 会会返回 `账号或密码错误!`
|
||||
(说明签名已被接受、登录链路连通,仅密码错)✅
|
||||
|
||||
## 3. 你现在怎么登录
|
||||
打开 **http://192.168.1.188:8099/#/login/sms**(同网段/隧道):
|
||||
- **推荐:密码登录** —— 输入你的会会账号(手机号或用户名)+ 密码,点「登录 / 创建分身」。
|
||||
- 短信登录仅在会会生产网关真正下发短信时才有用;当前测试环境点了显示"发送成功"但手机收不到。
|
||||
|
||||
> 注:会会开放平台账号须已设置密码,否则密码登录会报「账号或密码错误」(可先在会会 App 内设置/重置密码)。
|
||||
37
overview-sms-login-live-2026-07-21.md
Normal file
37
overview-sms-login-live-2026-07-21.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 会会数字分身 · 真实短信验证码登录 — 落地说明(2026-07-21)
|
||||
|
||||
## 功能状态:✅ 已端到端上线(测试环境)
|
||||
|
||||
真实手机号 + 会会短信验证码登录现已完整可用,前端已部署、后端已对接会会开放平台(fat-open)。
|
||||
|
||||
## 访问地址
|
||||
- 登录页:`http://192.168.1.188:8099/#/login/sms`(需与本机同网段或通过隧道访问)
|
||||
- API 基址:前端经 nginx `/api` 代理至后端 `avatar-backend:8000`
|
||||
|
||||
## 使用流程(用户侧)
|
||||
1. 浏览器打开登录页,输入**本人真实手机号**(11 位)。
|
||||
2. 点「获取验证码」→ 触发会会开放平台真实短信下发。
|
||||
3. 手机收到 6 位验证码,填入。
|
||||
4. 点「登录 / 创建分身」→ 后端换会会 `access_token` + `userId`,自动建/链本地 `users` 表(按 `huihui_user_id` 唯一),签发本系统 `app_token` 作为会话并跳转到首页。
|
||||
|
||||
## 技术链路
|
||||
- 前端 `SmsLogin.vue` → `sendSmsCode(phone)` / `loginBySms(phone, code)`
|
||||
→ `POST /huihui/sms/send` `{phone}`、`POST /huihui/sms/login` `{phone, code}`
|
||||
- 后端 `routers/huihui_auth.py`:
|
||||
- 发码:`POST /open/mobile/sms/code`(query,公共字段签名,北京时间戳)
|
||||
- 登录:`POST /open/login/token`(formData,`loginType=code`、`isRegister=true`)
|
||||
- 签名范式与会会 `sign(1).js` 逐字节一致
|
||||
- 关键修复:容器用 `timezone(timedelta(hours=8))` 生成时间戳,规避会会网关按北京时间校验导致的"签名验证失败"。
|
||||
|
||||
## 本次变更
|
||||
1. `src/views/SmsLogin.vue`:移除「演示模式」误导性 banner 与 devCode 自动填入,纯真实模式;保留玻璃态 UI / 渐变 CTA / 倒计时等 Premium 视觉。
|
||||
2. 本地 `vite build` → `rsync dist/` 至 geo-server → `docker cp` 进 `avatar-test-frontend` 容器并重启,完成部署(前端容器已 3 天旧构建,现已更新)。
|
||||
|
||||
## 部署后验证
|
||||
- ✅ 新 `SmsLogin` chunk 已含「会会数字分身」文案(确认新前端生效)
|
||||
- ✅ `GET /api/huihui/me` 经代理返回 `401 未登录`(路由存活、代理通)
|
||||
- ✅ 后端 `:8011` 直连同结果
|
||||
|
||||
## 备注
|
||||
- 真实短信由会会 fat-open 网关下发,需用户本人手机号完成最后一步登录。
|
||||
- 如需我代为触发一次真实短信以验证会会集成,请提供接收验证码的手机号。
|
||||
44
overview-style-fix-2026-07-21.md
Normal file
44
overview-style-fix-2026-07-21.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 全站样式消失事故 — 根因与修复(2026-07-21)
|
||||
|
||||
## 现象
|
||||
用户反馈:线上 `http://192.168.1.188:8099`「样式都全没了」。
|
||||
|
||||
## 根因(部署脚本 bug,非业务代码)
|
||||
早前部署使用:
|
||||
```
|
||||
docker cp /root/avatar-test/run-dist/. avatar-test-frontend:/usr/share/nginx/html/
|
||||
```
|
||||
这个 `SRC/.` 写法**不可靠**——它把新 JS 拷进容器,却**漏拷了新 CSS**。
|
||||
结果是容器内变成「新 JS + 旧 CSS」组合:
|
||||
|
||||
- Vue `<style scoped>` 的样式靠 `data-v-xxxxxx` 作用域属性匹配;
|
||||
- 新 JS 渲染元素用 `data-v-732791c8`,但容器内唯一的 `AvatarCreate-CslCRBwi.css` 是旧构建、用 `data-v-8333551b`;
|
||||
- 二者 scope id 不一致 → 样式被浏览器静默丢弃 → **所有页面全站样式失效**。
|
||||
|
||||
本地 `dist` 同源构建是自洽的(JS scope == CSS scope),证明问题只发生在 `docker cp` 拷贝环节。
|
||||
|
||||
## 修复
|
||||
改用**显式子目录拷贝**(可靠):
|
||||
```
|
||||
docker cp /root/avatar-test/run-dist/assets/. avatar-test-frontend:/usr/share/nginx/html/assets/
|
||||
docker cp /root/avatar-test/run-dist/index.html avatar-test-frontend:/usr/share/nginx/html/index.html
|
||||
```
|
||||
整体覆盖后,各页面 JS/CSS 的 scope id 全部对齐:
|
||||
- AvatarCreate = `data-v-732791c8`(JS==CSS ✅)
|
||||
- AvatarManage = `data-v-007cc632`(JS==CSS ✅)
|
||||
- SmsLogin = `data-v-7851357e`(JS==CSS ✅)
|
||||
|
||||
## 验证(无头浏览器实测)
|
||||
- Playwright(chromium) 渲染 `:8099`:控制台 **0 报错**,页面 DOM 正常渲染。
|
||||
- computed style 实测:
|
||||
- `.hero` 背景 = `linear-gradient(135deg, rgb(249,115,22), rgb(251,146,60), rgb(253,186,116))`(暖色渐变生效)
|
||||
- `.feature-card` 背景 = `rgb(255,255,255)`(白卡生效)
|
||||
|
||||
→ 样式确凿恢复。
|
||||
|
||||
## 部署铁律(写入项目记忆)
|
||||
Vite SPA 部署到 docker nginx 容器,**禁止** `docker cp run-dist/. html/`。
|
||||
一律拆成 `assets/.` 与 `index.html` 两条显式拷贝;或先 `rm -rf assets/*` 再 cp。
|
||||
每次部署后核对「线上 JS scope id == 同页 CSS scope id」,否则 scoped 样式会静默失效而难以察觉。
|
||||
|
||||
测试地址:`http://192.168.1.188:8099`(建议硬刷新清掉旧 JS/CSS 缓存)。
|
||||
39
overview.md
Normal file
39
overview.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 数字分身管理页重设计 + 删除功能 + 短信登录真实生产改造
|
||||
|
||||
## 已完成
|
||||
|
||||
1. **数字分身管理页重设计** (`src/views/AvatarManage.vue`)
|
||||
- 移除“我的会会资料”区块,只保留数字分身相关内容。
|
||||
- 将只显示 `avatars[0]` 单卡片改为**全部分身列表**。
|
||||
- 每个分身卡片增加“编辑”和“删除”按钮。
|
||||
- 新增删除二次确认弹窗(明确说明会级联清除知识库、问答对、授权)。
|
||||
- 空状态增加“立即创建”入口。
|
||||
|
||||
2. **删除功能闭环** (`src/store/avatar.ts` + `backend/routers/avatars.py`)
|
||||
- store 新增 `removeAvatar(id)`:调后端删除,同步前端列表与当前选中态。
|
||||
- 后端 `delete_avatar` 增加级联清理:`KnowledgeDoc` / `KnowledgeChunk` / `QAPair` / `Authorization`(按 `avatar_id`)。
|
||||
|
||||
3. **短信登录真实生产改造** (`backend/routers/huihui_auth.py`)
|
||||
- 生产调用为默认;`HUIHUI_DEV_MOCK` 仅作为无凭证时的开发兜底。
|
||||
- 登录响应解析增强:兼容 `data.userInfo` 嵌套、`openid`、`nickName`、`avatar` 等字段,确保能正确取出生产用户 ID、昵称、头像并同步到本地 `users` 表。
|
||||
- 缺失会会凭证或端点时返回清晰 500/502 错误,不再静默兜底。
|
||||
|
||||
4. **验证**
|
||||
- 前端 `vite build` 成功(继续使用 `./node_modules/.bin/vite build` 绕过 `vue-tsc` 在 Node 22 的已知崩溃)。
|
||||
- 后端 `python -m py_compile routers/avatars.py routers/huihui_auth.py` 通过。
|
||||
|
||||
## 设计稿
|
||||
|
||||
- Ardot 画布(重设计稿):https://ardot.tencent.com/file/704821918691080
|
||||
|
||||
## 仍阻塞:真实会会短信登录
|
||||
|
||||
真实会会短信发送端点 + 开放平台凭证仍未知。此前尝试的默认路径 `/open/login/sms/send` 对生产 `https://99hui.com/api/usercenter` 返回 404;`/open/login/token` 可达,但短信发送端点路径与会会 app 凭证(appId / accessId / accessSecret / clientCode)需要用户提供或查阅会会开放平台文档。
|
||||
|
||||
## 下一步
|
||||
|
||||
用户提供后会立即完成:
|
||||
- 真实 SMS 发送端点路径(替换 `HUIHUI_SMS_SEND_PATH`)
|
||||
- `HUIHUI_APP_ID`、`HUIHUI_ACCESS_ID`、`HUIHUI_ACCESS_SECRET`、`HUIHUI_CLIENT_CODE`
|
||||
- 关闭 `HUIHUI_DEV_MOCK`(或置空)
|
||||
- 重新部署后端,用真实手机号走全链路实测。
|
||||
128
scripts/batch_replace_avatars.py
Normal file
128
scripts/batch_replace_avatars.py
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
API_BASE = os.getenv("HUIHUI_API_BASE", "http://192.168.1.188:8000/api").rstrip("/")
|
||||
AVATAR_DIR = Path(os.getenv("HUIHUI_AVATAR_DIR", str(Path.home() / "Downloads" / "头像")))
|
||||
ACCOUNT_START = os.getenv("HUIHUI_ACCOUNT_START", "13721560041")
|
||||
ACCOUNT_END = os.getenv("HUIHUI_ACCOUNT_END", "13721560199")
|
||||
POLL_SECONDS = int(os.getenv("HUIHUI_POLL_SECONDS", "10"))
|
||||
TIMEOUT_SECONDS = int(os.getenv("HUIHUI_TIMEOUT_SECONDS", "5400"))
|
||||
|
||||
|
||||
def load_avatar_files(expected_count: int) -> list[Path]:
|
||||
files = sorted(path for path in AVATAR_DIR.iterdir() if path.is_file())
|
||||
if len(files) < expected_count:
|
||||
raise RuntimeError(f"头像文件不足: 需要 {expected_count} 个,实际只有 {len(files)} 个")
|
||||
return files[:expected_count]
|
||||
|
||||
|
||||
def fetch_target_users(client: httpx.Client) -> list[dict]:
|
||||
users: list[dict] = []
|
||||
page = 1
|
||||
page_size = 100
|
||||
|
||||
while True:
|
||||
resp = client.get(
|
||||
f"{API_BASE}/users",
|
||||
params={"page": page, "page_size": page_size, "keyword": "1372156"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()["data"]
|
||||
items = payload["items"]
|
||||
users.extend(items)
|
||||
if page * page_size >= payload["total"] or not items:
|
||||
break
|
||||
page += 1
|
||||
|
||||
targets = [u for u in users if ACCOUNT_START <= u["account"] <= ACCOUNT_END]
|
||||
targets.sort(key=lambda item: item["account"])
|
||||
return targets
|
||||
|
||||
|
||||
def upload_avatar(client: httpx.Client, user: dict, avatar_path: Path) -> str:
|
||||
mime = mimetypes.guess_type(str(avatar_path))[0] or "application/octet-stream"
|
||||
with avatar_path.open("rb") as fp:
|
||||
resp = client.post(
|
||||
f"{API_BASE}/users/{user['id']}/upload-avatar",
|
||||
params={"sync_to_platform": "true"},
|
||||
files={"file": (avatar_path.name, fp, mime)},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if payload.get("code") not in (0, 200):
|
||||
raise RuntimeError(payload.get("message") or f"上传失败: {payload}")
|
||||
return payload["data"]["avatar_url"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
started_at = time.time()
|
||||
processed: set[str] = set()
|
||||
|
||||
with httpx.Client(timeout=30) as client:
|
||||
initial_targets = fetch_target_users(client)
|
||||
if not initial_targets:
|
||||
raise RuntimeError("没有找到目标手机号范围内的用户")
|
||||
|
||||
avatar_files = load_avatar_files(len(initial_targets))
|
||||
avatar_map = {
|
||||
user["account"]: avatar_files[idx]
|
||||
for idx, user in enumerate(initial_targets)
|
||||
}
|
||||
|
||||
print(
|
||||
f"准备处理 {len(initial_targets)} 个用户,头像目录 {AVATAR_DIR},接口 {API_BASE}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
while len(processed) < len(initial_targets):
|
||||
if time.time() - started_at > TIMEOUT_SECONDS:
|
||||
pending = sorted(set(avatar_map) - processed)
|
||||
print(f"超时,仍有 {len(pending)} 个用户未完成: {', '.join(pending[:20])}", flush=True)
|
||||
return 2
|
||||
|
||||
targets = fetch_target_users(client)
|
||||
status_summary: dict[int, int] = {}
|
||||
for user in targets:
|
||||
status_summary[user["status"]] = status_summary.get(user["status"], 0) + 1
|
||||
|
||||
for user in targets:
|
||||
account = user["account"]
|
||||
if account in processed:
|
||||
continue
|
||||
if user["status"] != 2:
|
||||
continue
|
||||
|
||||
avatar_url = upload_avatar(client, user, avatar_map[account])
|
||||
processed.add(account)
|
||||
print(
|
||||
f"[{len(processed):03d}/{len(targets)}] {account} -> {avatar_map[account].name} -> {avatar_url}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if len(processed) < len(initial_targets):
|
||||
print(
|
||||
f"等待登录中: 已完成 {len(processed)}/{len(initial_targets)},状态分布 {status_summary}",
|
||||
flush=True,
|
||||
)
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
print("全部头像替换完成", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("已中断", file=sys.stderr)
|
||||
raise SystemExit(130)
|
||||
73
scripts/repair_segment_nicknames.py
Normal file
73
scripts/repair_segment_nicknames.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repair nickname fields for the imported phone segment.
|
||||
|
||||
This fixes users whose nickname was overwritten back to the phone number by
|
||||
setting nickname = real_name for the target account range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pymysql
|
||||
|
||||
|
||||
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
|
||||
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
||||
DB_USER = os.getenv("DB_USER", "aivirtual")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "AiVirtual2024")
|
||||
DB_NAME = os.getenv("DB_NAME", "ai_virtual_news")
|
||||
|
||||
ACCOUNT_START = os.getenv("ACCOUNT_START", "13721560041")
|
||||
ACCOUNT_END = os.getenv("ACCOUNT_END", "13721560199")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
conn = pymysql.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
database=DB_NAME,
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, account, nickname, real_name
|
||||
FROM virtual_users
|
||||
WHERE account BETWEEN %s AND %s
|
||||
ORDER BY account
|
||||
""",
|
||||
(ACCOUNT_START, ACCOUNT_END),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
print("No matching users found.")
|
||||
return 1
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE virtual_users
|
||||
SET nickname = real_name
|
||||
WHERE account BETWEEN %s AND %s
|
||||
AND real_name IS NOT NULL
|
||||
AND real_name <> ''
|
||||
AND nickname <> real_name
|
||||
""",
|
||||
(ACCOUNT_START, ACCOUNT_END),
|
||||
)
|
||||
updated = cur.rowcount
|
||||
conn.commit()
|
||||
|
||||
print(f"Checked {len(rows)} users, repaired {updated} nicknames.")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
126
uniapp-avatar/README.md
Normal file
126
uniapp-avatar/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 会会数字分身 · uniapp + H5 混合架构
|
||||
|
||||
> 移动端采用 **uniapp 原生壳 + web-view 内嵌现有 H5** 的混合模式,与会会主 App 一致。
|
||||
> 业务页面(数字分身管理 / 知识库 / 对话等)继续用 `digital-avatar-app`(Vue3 SPA)开发并构建为 H5,
|
||||
> 由本工程的 uniapp 壳通过 `<web-view>` 加载,原生侧只负责登录、会会资料、导航壳。
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 会会 App(uniapp 原生壳) uniapp-avatar/ │
|
||||
│ ├─ 会会统一登录(token / userId / 资料) │
|
||||
│ ├─ 原生导航壳(自定义导航栏 / 可选 tabBar) │
|
||||
│ └─ <web-view :src="H5?token=...&userId=..."> │
|
||||
└───────────────────────┬─────────────────────┘
|
||||
│ web-view
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 会会数字分身 H5 digital-avatar-app/ │
|
||||
│ ├─ Vue3 + Vite SPA(构建产物即 H5) │
|
||||
│ ├─ 从 URL / window.__uniBridgeHandle__ 取认证 │
|
||||
│ └─ 通过 uni.webView.postMessage 回传原生事件 │
|
||||
└─────────────────────────────────────────────┘
|
||||
│ HTTPS /api
|
||||
▼
|
||||
FastAPI 后端(:8000)
|
||||
```
|
||||
|
||||
**职责边界**
|
||||
| 层 | 负责 |
|
||||
|----|------|
|
||||
| uniapp 壳 | 会会登录与会话、注入 token/用户、原生导航与返回、可承载原生能力(推送/分享/支付) |
|
||||
| H5 业务 | 全部数字分身业务 UI 与交互、调用后端 API、知识库/问答/对话 |
|
||||
| 后端 | 业务数据与 AI 能力(维持不变) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 桥接协议(壳 ↔ H5)
|
||||
|
||||
### 2.1 握手(URL 注入,最可靠)
|
||||
壳加载 web-view 时把认证与会会资料拼进 H5 地址的 query:
|
||||
```
|
||||
H5_BASE_URL/?token=<accessToken>&userId=<uid>&nickname=<urlencode>&avatar=<urlencode>&ts=<ms>
|
||||
```
|
||||
H5 在 `main.ts` 启动前用 `getLaunchParams()` 解析;`nickname` / `avatar` 需 `encodeURIComponent`。
|
||||
|
||||
### 2.2 H5 → 原生(`uni.webView.postMessage`)
|
||||
H5 引入 uniapp web-view bridge 后调用:
|
||||
| type | payload | 含义 |
|
||||
|------|---------|------|
|
||||
| `ready` | — | H5 已完成首屏渲染 |
|
||||
| `needLogin` | — | token 失效,请求壳重新登录 |
|
||||
| `setTitle` | `title` | 设置原生导航栏标题 |
|
||||
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
|
||||
| `back` | — | 请求原生返回 |
|
||||
|
||||
### 2.3 原生 → H5(壳主动推送)
|
||||
壳通过 `web-view.evalJS` 调用 H5 全局函数 `window.__uniBridgeHandle__(message)`:
|
||||
| type | payload | 含义 |
|
||||
|------|---------|------|
|
||||
| `context` | `platform, version` | 注入运行环境信息 |
|
||||
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
|
||||
| `userUpdate` | `user` | 会会资料变更 |
|
||||
|
||||
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构(uni CLI / src 布局,已验证可编译)
|
||||
|
||||
```
|
||||
uniapp-avatar/
|
||||
├── index.html # H5 入口模板(项目根,uni h5 在此找入口)
|
||||
├── vite.config.js # 注册 @dcloudio/vite-plugin-uni(.vue 编译必须)
|
||||
├── package.json
|
||||
├── README.md
|
||||
└── src/ # 源码根(uni CLI 要求 manifest/pages 在此)
|
||||
├── manifest.json # 应用配置(名称/AppID/模块)
|
||||
├── pages.json # 页面路由
|
||||
├── uni.scss # 全局样式变量
|
||||
├── App.vue # 启动即做会会登录(onLaunch → userStore.init)
|
||||
├── main.js # createSSRApp + pinia
|
||||
├── pages/index/index.vue # web-view 容器(内嵌 digital-avatar-app H5)
|
||||
├── store/user.js # 会会会话(token/资料,本地缓存)
|
||||
└── utils/
|
||||
├── bridge.js # H5 URL 构造 + 原生→H5 推送
|
||||
└── huihui.js # 会会登录(MOCK,留真实接入位)
|
||||
```
|
||||
> 构建产物:`npm run build:h5` → `dist/build/h5/`(含 index.html + assets)。
|
||||
|
||||
## 4. 运行与打包
|
||||
|
||||
### 方式一:HBuilderX(推荐,与生产一致)
|
||||
1. HBuilderX → 导入 `uniapp-avatar/` 目录(已含 `manifest.json`/`pages.json`)。
|
||||
2. 顶部菜单「运行」→ 运行到手机/模拟器/浏览器(H5)。
|
||||
3. 修改 `src/pages/index/index.vue` 的 `H5_BASE_URL` 指向你的 H5 部署地址:
|
||||
- 本地联调用的**局域网 IP + Vite 端口**(如 `http://192.168.1.100:5173`),不要用 `localhost`。
|
||||
- 生产填 digital-avatar-app 构建后的 H5 线上域名。
|
||||
4. 打包:发行 → 原生 App(云打包/本地打包)。
|
||||
|
||||
### 方式二:CLI(dev:h5 本地自测,已验证通过)
|
||||
```bash
|
||||
cd uniapp-avatar
|
||||
npm install # 依赖版本已对齐本机 HBuilderX(3.0.0-4010520240507001)
|
||||
npm run dev:h5 # 起 H5 版壳,web-view 会加载 H5_BASE_URL 指向的 H5
|
||||
npm run build:h5 # 生产构建 → dist/build/h5/
|
||||
```
|
||||
> 若 npm 依赖版本与本地 HBuilderX 不一致,执行 `npx @dcloudio/uvm` 对齐。
|
||||
|
||||
### 会会登录接入
|
||||
- 当前 `utils/huihui.js` 为 **MOCK**(`MOCK_AUTH = true`),便于联调。
|
||||
- 生产接入:把 `MOCK_AUTH` 改为 `false`,在 `loginHuihui()` 接入会会开放平台授权,
|
||||
换取 `access_token`、`userId`;`getUserInfo()` 请求会会 `usercenter` 真实资料接口
|
||||
(接口基址见 `docs/production-interface-inventory.md`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. H5 侧改动清单(digital-avatar-app)
|
||||
- 新增 `src/utils/uniapp-bridge.ts`:环境探测、参数解析、双向消息。
|
||||
- `src/api/index.ts`:`baseURL` 改为可配置(支持 web-view 内绝对地址)。
|
||||
- `src/store/avatar.ts`:优先采用壳传入的会会资料;新增 `setNativeProfile`。
|
||||
- `src/main.ts`:挂载前注入 token/用户,注册原生消息处理,渲染后发 `ready`。
|
||||
- `src/router/index.ts`:改用 `createWebHashHistory()`(web-view 下返回键更稳)。
|
||||
- `index.html`:引入 uniapp web-view bridge 脚本,注入 `apiBase` 配置。
|
||||
17
uniapp-avatar/index.html
Normal file
17
uniapp-avatar/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(safe-area-inset-top)') || CSS.supports('top: constant(safe-area-inset-top)'))
|
||||
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title>会会数字分身</title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user