feat: complete huihui square avatar workflows

This commit is contained in:
stefanfeng
2026-07-24 14:04:21 +08:00
parent 2ef58a44b8
commit e3bda469bb
68 changed files with 8062 additions and 132 deletions

View File

@@ -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

View File

@@ -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))

View File

@@ -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("✅ 数据库初始化完成")

View File

@@ -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():

View File

@@ -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"

View File

@@ -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))

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.git/
.env
*.log
backend/

4
digital-avatar-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
*.log

View 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

View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.db
.env
logs/
*.log

View 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 . .
# 后端使用 SQLiteavatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View 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

View 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),
}

View File

@@ -0,0 +1,9 @@
fastapi
uvicorn[standard]
sqlalchemy
pydantic
python-multipart
httpx
pypdf
python-docx
openpyxl

View 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}

View 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])

View 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})

View 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/tokenloginType=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 偏移(不依赖 tzdataslim 镜像缺 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 一致yyyyMMddHHmmss24 小时制大写 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/codemobile 与公共字段均走 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})

View 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())

View 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})

View 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

View 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.webViewH5 才能与原生壳通信 -->
<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>

View File

@@ -0,0 +1,50 @@
# 牙齿防护知识手册
> 本手册用于「会会」数字分身知识库,覆盖日常护牙、牙线使用、饮食护牙、定期检查、常见口腔问题及儿童口腔保健,供分身专业应答引用。
## 一、日常刷牙
- **频率与时机**:每天早晚各刷一次,睡前刷牙尤为重要;饭后建议漱口,进食酸性食物后等待约 30 分钟再刷牙,避免即刻磨损被酸软化的牙釉质。
- **巴氏刷牙法Bass**:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤 10 次左右,再向牙冠方向拂刷;依次覆盖牙齿外侧、内侧与咬合面。
- **时长**:每次刷牙至少 2 分钟。
- **工具**:选用软毛、小头牙刷;刷毛外翻或每 3 个月更换一次。
- **牙膏**:推荐使用含氟牙膏,氟可增强牙釉质抗酸与再矿化能力。
## 二、牙线 / 牙缝清洁
- 每天至少使用一次牙线,清洁牙刷难以到达的牙齿邻面。
- 取约 45cm 牙线,以 C 形包绕牙面,上下刮擦清除菌斑。
- 牙缝较大或牙周炎人群,可配合使用牙间刷(间隙刷)。
## 三、饮食与护牙
- 控制游离糖摄入,少喝含糖饮料、少吃黏性甜食——糖是致龋的主要元凶。
- 酸性饮料(碳酸饮料、果汁)会侵蚀牙釉质,建议用吸管饮用并尽快漱口,勿立即刷牙。
- 多喝水,唾液具有自我清洁与再矿化作用。
- 适量补充钙、磷、维生素 D奶类、豆制品、深绿色蔬菜
## 四、定期检查与洁治
- 建议每 6 个月进行一次口腔检查与洁牙(洗牙)。
- 洗牙清除牙石与菌斑,预防牙龈炎、牙周炎;**洗牙不会让牙缝变大**(牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成)。
- 出现龋齿、牙龈出血、口臭、牙齿敏感应及时就医,越早处理越简单。
## 五、常见口腔问题
- **龋齿(蛀牙)**:浅龋可涂氟干预,但已形成龋洞必须充填,无法自愈。
- **牙周病**:表现为牙龈红肿、出血、口臭、牙齿松动;基础治疗为洁治 + 龈下刮治,需长期维护。
- **牙齿敏感**:遇冷热酸甜刺激痛,可用抗敏感牙膏;若持续加重需就诊排查楔状缺损或牙龈退缩。
- **智齿**:阻生或反复发炎的智齿建议拔除。
## 六、儿童口腔保健
- 第一颗乳牙萌出(约 6 月龄)即开始清洁,可用纱布或指套牙刷。
- **含氟牙膏用量**3 岁以下用米粒大小36 岁用豌豆大小,均需在家长监督下使用并防止误吞。
- **窝沟封闭**67 岁六龄齿萌出后做窝沟封闭,有效预防窝沟龋。
- 定期涂氟,每半年一次口腔检查。
## 七、常见误区澄清
- 误区「洗牙伤牙」:事实为规范洗牙安全,不损伤牙釉质。
- 误区「牙龈出血就不能刷」:事实为出血多因炎症,更应保持清洁并尽早就医。
- 误区「乳牙坏了不用管」:事实为乳牙健康直接影响恒牙与颌骨发育。

View 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 岁以下用米粒大小含氟牙膏36 岁用豌豆大小,均需监督防误吞。",
"enabled": true
},
{
"question": "儿童六龄齿需要做窝沟封闭吗?",
"answer": "建议 67 岁六龄齿萌出后做窝沟封闭,并定期涂氟、每半年口腔检查,可有效预防窝沟龋,保护恒牙。",
"enabled": true
},
{
"question": "喝碳酸饮料会伤牙吗?怎么喝更好?",
"answer": "碳酸饮料酸性强,会侵蚀牙釉质。建议用吸管减少接触、喝完尽快清水漱口,不要立即刷牙,并控制频次。",
"enabled": true
},
{
"question": "多久做一次口腔检查和洗牙?",
"answer": "建议每 6 个月进行一次口腔检查与洁牙,以便早期发现龋齿、牙周问题并及时干预。",
"enabled": true
}
]

View File

@@ -0,0 +1,38 @@
# 完整主配置:覆盖 nginx:alpine 默认 /etc/nginx/nginx.conf
# 新版 nginx 在受限容器内写 /run/nginx.pid 会报 Operation not permitted 并致命退出,
# 这里把 pid 显式改到可写的 /tmpmain 上下文唯一一处),避免前端容器反复重启。
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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,119 @@
<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>(true)
// 监听路由变化
watch(() => route.path, (newPath) => {
currentRoute.value = newPath
// 首页(创建引导)与编辑页隐藏底部导航
showNav.value = newPath !== '/' && !newPath.startsWith('/avatar/edit')
})
// 导航
const navigateTo = (path: string) => {
router.push(path)
}
onMounted(() => {
currentRoute.value = route.path
showNav.value = route.path !== '/' && !route.path.startsWith('/avatar/edit')
})
</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>

View 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()

View 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
}
})

View File

@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

View 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 }
})

View 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;
}

View 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.webViewH5 即可用 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' })
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"]
}

View 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
}
}
}
})

View File

@@ -0,0 +1,96 @@
# 数字分身管理系统 · 今日工作总结
**日期**2026-07-08
**负责人**Senior Developer高级开发工程师
**项目目录**`digital-avatar-app`
**技术栈**Vue3 + Vite + TypeScript + Pinia + Vue Router / FastAPI + SQLAlchemy + SQLite
---
## 一、项目背景与目标
- **产品线**「会会」HuiHuiAI 个人数字分身(云 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 12501 分身) |
| 生产构建(`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 代码,未在画布上直接设计。

View 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` 为空,这和“部分用户点立即互动时取不到文章列表”现象高度相关,建议后续继续排查组织信息自动发现链路。

View File

@@ -1,6 +1,7 @@
server {
listen 80;
server_name _;
client_max_body_size 10m;
root /usr/share/nginx/html;
index index.html;

View File

@@ -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>

View File

@@ -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 || '取消失败')
}

View 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` 已优先覆盖,逻辑自动生效。
- 表情选项和头像选项为互斥;用户可在创建流程中随时切换。

View 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` 预发环境建议继续用账号密码登录。
- 可在创建页支持用户上传/裁剪自定义头像,替代目前直接沿用会会头像的方案。

View 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`

View 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直连真实网关验证签名避免盲部署。

View 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 壳工程中,做真机视觉走查。

View 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`(原生壳注入 / 启动桥接)是可空、可被空值覆盖的派生源,绝不能放在 `||` 左侧优先。

View 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 内设置/重置密码)。

View 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 网关下发,需用户本人手机号完成最后一步登录。
- 如需我代为触发一次真实短信以验证会会集成,请提供接收验证码的手机号。

View 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
View 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`(或置空)
- 重新部署后端,用真实手机号走全链路实测。

View 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)

View 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
View File

@@ -0,0 +1,126 @@
# 会会数字分身 · uniapp + H5 混合架构
> 移动端采用 **uniapp 原生壳 + web-view 内嵌现有 H5** 的混合模式,与会会主 App 一致。
> 业务页面(数字分身管理 / 知识库 / 对话等)继续用 `digital-avatar-app`Vue3 SPA开发并构建为 H5
> 由本工程的 uniapp 壳通过 `<web-view>` 加载,原生侧只负责登录、会会资料、导航壳。
---
## 1. 整体架构
```
┌─────────────────────────────────────────────┐
│ 会会 Appuniapp 原生壳) 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云打包/本地打包)。
### 方式二CLIdev:h5 本地自测,已验证通过)
```bash
cd uniapp-avatar
npm install # 依赖版本已对齐本机 HBuilderX3.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
View 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>