feat: complete huihui square avatar workflows
This commit is contained in:
@@ -52,20 +52,45 @@ async def retry_interaction(record_id: int, db=Depends(get_db)):
|
||||
if not user or user.status != 2:
|
||||
raise HTTPException(status_code=400, detail="用户未登录,无法重试")
|
||||
|
||||
success, err = False, "未知类型"
|
||||
success, err, platform_record_id = False, "未知类型", ""
|
||||
if record.interact_type == "comment" and record.content:
|
||||
success, err = await news_service.post_comment(db, user, record.article_id, record.article_title or "", record.content)
|
||||
success, err, platform_record_id = await news_service.post_comment_with_record_id(
|
||||
db, user, record.article_id, record.article_title or "", record.content
|
||||
)
|
||||
elif record.interact_type == "like":
|
||||
success, err = await news_service.like_news(db, user, record.article_id, org_id="", title=record.article_title or "")
|
||||
elif record.interact_type == "collect":
|
||||
success, err = await news_service.collect_news(db, user, record.article_id, title=record.article_title or "")
|
||||
elif record.interact_type == "forward":
|
||||
success, err = await news_service.forward_news(db, user, record.article_id)
|
||||
elif record.interact_type == "reply" and record.content:
|
||||
parent_comment = None
|
||||
if record.parent_comment_id:
|
||||
comments = await news_service.get_comments(db, user, record.article_id or "")
|
||||
parent_comment = next(
|
||||
(
|
||||
c for c in comments
|
||||
if str(c.get("id") or c.get("commentId") or "") == str(record.parent_comment_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not parent_comment:
|
||||
success, err, platform_record_id = False, "未找到被回复的原评论,无法重试回复", ""
|
||||
else:
|
||||
success, err, platform_record_id = await news_service.post_reply_with_record_id(
|
||||
db, user,
|
||||
record.article_id or "",
|
||||
record.parent_comment_id or "",
|
||||
record.content,
|
||||
parent_comment=parent_comment,
|
||||
article_title=record.article_title or "",
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=1 if success else 2,
|
||||
error_msg=None if success else err,
|
||||
platform_record_id=platform_record_id or record.platform_record_id,
|
||||
retry_count=record.retry_count + 1,
|
||||
)
|
||||
)
|
||||
@@ -147,15 +172,69 @@ async def cancel_interaction(record_id: int, db=Depends(get_db)):
|
||||
news_id=record.article_id or "",
|
||||
title=record.article_title or "",
|
||||
)
|
||||
elif record.interact_type in ("comment", "reply"):
|
||||
elif record.interact_type == "comment":
|
||||
comment_id = record.platform_record_id or ""
|
||||
if not comment_id:
|
||||
return ApiResponse(code=400, message="缺少评论ID,无法删除")
|
||||
comment_id, comment_count = await news_service.find_comment_id(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
content=record.content or "",
|
||||
)
|
||||
if comment_id:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
platform_record_id=comment_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
elif comment_count == 0:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=3,
|
||||
error_msg="平台未查询到评论,已标记取消",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="平台未查询到评论,已标记取消")
|
||||
else:
|
||||
return ApiResponse(code=400, message="缺少评论ID,且未能从平台评论列表反查到该评论")
|
||||
ok, err = await news_service.cancel_comment(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
comment_id=comment_id,
|
||||
)
|
||||
elif record.interact_type == "reply":
|
||||
reply_id = record.platform_record_id or ""
|
||||
if not reply_id:
|
||||
reply_id, reply_count = await news_service.find_reply_id(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
content=record.content or "",
|
||||
parent_comment_id=record.parent_comment_id or "",
|
||||
)
|
||||
if reply_id:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
platform_record_id=reply_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
elif reply_count == 0:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=3,
|
||||
error_msg="平台未查询到回复,已标记取消",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="平台未查询到回复,已标记取消")
|
||||
else:
|
||||
return ApiResponse(code=400, message="缺少回复ID,且未能从平台回复列表反查到该回复")
|
||||
ok, err = await news_service.cancel_reply(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
reply_id=reply_id,
|
||||
)
|
||||
|
||||
if ok:
|
||||
# 更新状态为手动取消(status=3)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""虚拟用户管理接口"""
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import mimetypes
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import io
|
||||
@@ -9,6 +12,21 @@ from app.schemas import ApiResponse, UserCreateRequest, UserUpdateRequest, UserB
|
||||
from app.services.user_service import user_service
|
||||
|
||||
router = APIRouter()
|
||||
_UPLOADS_DIR = Path(__file__).resolve().parents[2] / "uploads" / "avatars"
|
||||
_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def _save_local_avatar(file_bytes: bytes, filename: str, content_type: str | None) -> str:
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
guessed_ext = mimetypes.guess_extension(content_type or "") or ".jpg"
|
||||
ext = ".jpg" if guessed_ext == ".jpe" else guessed_ext
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
ext = ".jpg"
|
||||
|
||||
target = _UPLOADS_DIR / f"{uuid.uuid4().hex}{ext}"
|
||||
target.write_bytes(file_bytes)
|
||||
return f"/api/uploads/avatars/{target.name}"
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -268,13 +286,20 @@ async def sync_all_profiles(db=Depends(get_db)):
|
||||
sess = await get_session(uid)
|
||||
if not sess:
|
||||
return False
|
||||
ur = await s.execute(select(_VU).where(_VU.id == uid))
|
||||
user = ur.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
platform_uid = sess.get("platform_uid", "")
|
||||
# 登录成功时 session 里已存有用户信息
|
||||
vals = {}
|
||||
if platform_uid: vals["platform_uid"] = platform_uid
|
||||
# session 里的字段(登录时写入)
|
||||
if sess.get("nickname"): vals["nickname"] = sess["nickname"]
|
||||
if sess.get("real_name"): vals["real_name"] = sess["real_name"]
|
||||
sync_nickname = sess.get("nickname", "")
|
||||
sync_real_name = sess.get("real_name", "")
|
||||
resolved_nickname = news_service._resolve_synced_nickname(user, sync_nickname, sync_real_name)
|
||||
if resolved_nickname: vals["nickname"] = resolved_nickname
|
||||
if sync_real_name: vals["real_name"] = sync_real_name
|
||||
if sess.get("sex"): vals["sex"] = int(sess["sex"])
|
||||
if sess.get("avatar"): vals["avatar_url"] = sess["avatar"]
|
||||
if vals:
|
||||
@@ -327,9 +352,8 @@ async def upload_avatar(
|
||||
else:
|
||||
return ApiResponse(code=500, message=f"头像上传到平台失败: {result}")
|
||||
else:
|
||||
# 仅本地存储(转 base64 或存储到本地)
|
||||
import base64
|
||||
avatar_url = f"data:{file.content_type};base64,{base64.b64encode(file_bytes).decode()}"
|
||||
# 未登录用户本地落盘,避免 base64 超过 avatar_url 字段长度
|
||||
avatar_url = await _save_local_avatar(file_bytes, file.filename or "", file.content_type)
|
||||
|
||||
# 更新数据库
|
||||
await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url))
|
||||
|
||||
@@ -62,7 +62,7 @@ async def init_db():
|
||||
# 导入所有模型类,确保 SQLAlchemy ORM 元数据注册
|
||||
from app.models import (
|
||||
VirtualUser, UserPersonality, InteractionRecord,
|
||||
TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
)
|
||||
logger.info("✅ 数据库模型注册成功")
|
||||
logger.info("✅ 数据库初始化完成")
|
||||
|
||||
@@ -6,7 +6,9 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import re as _re
|
||||
from pathlib import Path
|
||||
|
||||
# 修复 Pydantic v2 把 naive datetime 序列化为 +00:00 的问题
|
||||
# 数据库存的是北京时间,应标记为 +08:00
|
||||
@@ -89,6 +91,10 @@ app.add_middleware(
|
||||
# 注册路由
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
uploads_dir = Path(__file__).resolve().parent / "uploads"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/api/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
||||
@@ -75,6 +75,33 @@ class InteractionRecord(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class PendingReplyTask(Base):
|
||||
__tablename__ = "pending_reply_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
actor_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
next_actor_user_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
||||
news_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
news_title: Mapped[str | None] = mapped_column(String(256))
|
||||
article_org_id: Mapped[str | None] = mapped_column(String(64))
|
||||
parent_comment_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
parent_comment: Mapped[dict | None] = mapped_column(JSON)
|
||||
reply_to: Mapped[dict | None] = mapped_column(JSON)
|
||||
root_content: Mapped[str | None] = mapped_column(Text)
|
||||
context: Mapped[str | None] = mapped_column(Text)
|
||||
next_probability: Mapped[float] = mapped_column(Float, default=0.3)
|
||||
next_delay_min_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
next_delay_max_seconds: Mapped[int] = mapped_column(Integer, default=7200)
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0, index=True) # 0待发送 1发送中 2已发送 3失败
|
||||
attempts: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
last_error: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TokenStat(Base):
|
||||
__tablename__ = "token_stats"
|
||||
|
||||
|
||||
@@ -190,6 +190,32 @@ class AIService:
|
||||
请对上面的评论写一条友善自然的回复,{word_min}~{word_max}字,直接输出回复内容。"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=150)
|
||||
|
||||
async def generate_thread_reply(
|
||||
self, db: AsyncSession, article_title: str, root_comment: str,
|
||||
reply_context: str, personality_prompt: str,
|
||||
word_min: int = 15, word_max: int = 60
|
||||
) -> tuple[str, int]:
|
||||
"""结合文章、原评论和上下文生成评论回复链中的下一句。"""
|
||||
system_prompt = f"""你是一名真实的社区用户。{personality_prompt}
|
||||
|
||||
重要规则:
|
||||
- 回复必须积极正面、文明友善,不含任何敏感违规内容
|
||||
- 要自然接住对方的话,不要机械复述
|
||||
- 不要透露自己是AI或虚拟用户"""
|
||||
prompt = f"""文章标题:{article_title}
|
||||
原评论:{root_comment}
|
||||
当前对话上下文:{reply_context}
|
||||
|
||||
请结合文章、原评论和当前对话,写一条自然的后续回复。
|
||||
要求:
|
||||
1. 字数 {word_min}~{word_max} 字
|
||||
2. 语气像真实用户交流,可以认同、补充或追问
|
||||
3. 必须围绕文章和评论内容,不要跑题
|
||||
4. 只输出回复正文,不要加任何前缀或解释
|
||||
|
||||
回复:"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=180)
|
||||
|
||||
async def test_model(self, db: AsyncSession, model_id: int, test_prompt: str) -> dict:
|
||||
"""测试模型可用性"""
|
||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||
|
||||
@@ -7,7 +7,7 @@ import uuid
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
@@ -45,6 +45,31 @@ class NewsPlatformService:
|
||||
"orgId": await self._cfg(db, "platform_org_id", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _api_root(base_url: str) -> str:
|
||||
if "/api/" in base_url:
|
||||
return base_url.split("/api/", 1)[0] + "/api"
|
||||
return base_url.rstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_synced_nickname(user: VirtualUser, sync_nickname: str, sync_real_name: str) -> str:
|
||||
"""避免登录同步把有意义的本地昵称覆盖回手机号。"""
|
||||
current_nickname = (user.nickname or "").strip()
|
||||
current_real_name = (user.real_name or "").strip()
|
||||
sync_nickname = (sync_nickname or "").strip()
|
||||
sync_real_name = (sync_real_name or "").strip()
|
||||
account = (user.account or "").strip()
|
||||
|
||||
if current_nickname and current_nickname != account:
|
||||
return current_nickname
|
||||
if sync_nickname and sync_nickname != account:
|
||||
return sync_nickname
|
||||
if sync_real_name:
|
||||
return sync_real_name
|
||||
if current_real_name:
|
||||
return current_real_name
|
||||
return sync_nickname or current_nickname or account
|
||||
|
||||
# ─── 签名(完全对应 sign.js 逻辑) ─────────────────────────
|
||||
@staticmethod
|
||||
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
|
||||
@@ -188,6 +213,7 @@ class NewsPlatformService:
|
||||
sync_real_name = user_info.get("realName") or ""
|
||||
sync_sex = int(user_info.get("sex") or 0)
|
||||
sync_avatar = user_info.get("avatar") or ""
|
||||
preferred_nickname = self._resolve_synced_nickname(user, sync_nickname, sync_real_name)
|
||||
|
||||
await set_session(user.id, {
|
||||
"token": access_token,
|
||||
@@ -196,7 +222,7 @@ class NewsPlatformService:
|
||||
"org_id": org_id or cfg.get("orgId", ""),
|
||||
"login_time": datetime.now().isoformat(),
|
||||
# 缓存用户信息供 sync 使用
|
||||
"nickname": sync_nickname,
|
||||
"nickname": preferred_nickname,
|
||||
"real_name": sync_real_name,
|
||||
"sex": sync_sex,
|
||||
"avatar": sync_avatar,
|
||||
@@ -209,7 +235,7 @@ class NewsPlatformService:
|
||||
last_login_at=datetime.now(),
|
||||
platform_uid=platform_uid,
|
||||
)
|
||||
if sync_nickname: update_vals["nickname"] = sync_nickname
|
||||
if preferred_nickname: update_vals["nickname"] = preferred_nickname
|
||||
if sync_real_name: update_vals["real_name"] = sync_real_name
|
||||
if sync_sex: update_vals["sex"] = sync_sex
|
||||
if sync_avatar: update_vals["avatar_url"] = sync_avatar
|
||||
@@ -719,51 +745,287 @@ class NewsPlatformService:
|
||||
return False
|
||||
|
||||
async def post_comment(self, db, user, news_id, news_title, content, news_author_id="", org_id="") -> tuple[bool, str]:
|
||||
success, err, _ = await self.post_comment_with_record_id(
|
||||
db, user, news_id, news_title, content,
|
||||
news_author_id=news_author_id, org_id=org_id,
|
||||
)
|
||||
return success, err
|
||||
|
||||
async def post_comment_with_record_id(
|
||||
self, db, user, news_id, news_title, content, news_author_id="", org_id=""
|
||||
) -> tuple[bool, str, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
return False, "未登录", ""
|
||||
biz = await self._biz_url(db)
|
||||
cfg = await self._client(db)
|
||||
uid = sess.get("platform_uid", "")
|
||||
# org_id 优先取文章自带的(从广场数据获取),否则取 session/配置
|
||||
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
|
||||
if not final_org_id:
|
||||
final_org_id = await self._get_article_org_id(db, sess["token"], news_id)
|
||||
body = {
|
||||
"module": "news", "topicId": news_id, "title": news_title,
|
||||
"content": content, "orgId": final_org_id,
|
||||
"toUserId": news_author_id or uid, "userId": uid,
|
||||
"userName": user.nickname, "avatar": user.avatar_url or "",
|
||||
}
|
||||
return await self._json_post(f"{biz}/message/comment", self._bearer(sess["token"]), body)
|
||||
success, err, record_id = await self._json_post_with_record_id(
|
||||
f"{biz}/message/comment", self._bearer(sess["token"]), body
|
||||
)
|
||||
if success and not record_id:
|
||||
record_id, _ = await self.find_comment_id(db, user, news_id, content)
|
||||
return success, err, record_id
|
||||
|
||||
async def post_reply(self, db, user, news_id, comment_id, content) -> tuple[bool, str]:
|
||||
async def post_reply(
|
||||
self, db, user, news_id, comment_id, content,
|
||||
parent_comment: dict | None = None,
|
||||
reply_to: dict | None = None,
|
||||
article_title: str = "",
|
||||
org_id: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
success, err, _ = await self.post_reply_with_record_id(
|
||||
db, user, news_id, comment_id, content,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
article_title=article_title,
|
||||
org_id=org_id,
|
||||
)
|
||||
return success, err
|
||||
|
||||
async def post_reply_with_record_id(
|
||||
self, db, user, news_id, comment_id, content,
|
||||
parent_comment: dict | None = None,
|
||||
reply_to: dict | None = None,
|
||||
article_title: str = "",
|
||||
org_id: str = "",
|
||||
) -> tuple[bool, str, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
return False, "未登录", ""
|
||||
biz = await self._biz_url(db)
|
||||
uid = sess.get("platform_uid", "")
|
||||
cfg = await self._client(db)
|
||||
parent_comment = parent_comment or {}
|
||||
reply_to = reply_to or None
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or comment_id or "")
|
||||
parent_user_id = str(
|
||||
parent_comment.get("createUser")
|
||||
or parent_comment.get("userId")
|
||||
or parent_comment.get("commentUserId")
|
||||
or parent_comment.get("fromUserId")
|
||||
or ""
|
||||
)
|
||||
parent_user_name = (
|
||||
parent_comment.get("userName")
|
||||
or parent_comment.get("fromUserName")
|
||||
or parent_comment.get("nickName")
|
||||
or ""
|
||||
)
|
||||
reply_target = reply_to or parent_comment
|
||||
reply_target_id = str(
|
||||
reply_target.get("id")
|
||||
or reply_target.get("replyId")
|
||||
or reply_target.get("commentId")
|
||||
or parent_comment_id
|
||||
)
|
||||
reply_target_user_id = str(
|
||||
reply_target.get("createUser")
|
||||
or reply_target.get("fromUserId")
|
||||
or reply_target.get("userId")
|
||||
or reply_target.get("commentUserId")
|
||||
or parent_user_id
|
||||
or ""
|
||||
)
|
||||
reply_target_user_name = (
|
||||
reply_target.get("fromUserName")
|
||||
or reply_target.get("userName")
|
||||
or reply_target.get("nickName")
|
||||
or parent_user_name
|
||||
or ""
|
||||
)
|
||||
from_user_name = (
|
||||
(user.real_name or "").strip()
|
||||
or (user.nickname or "").strip()
|
||||
or (user.account or "").strip()
|
||||
)
|
||||
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
|
||||
if not final_org_id:
|
||||
final_org_id = await self._get_article_org_id(db, sess["token"], news_id)
|
||||
body = {
|
||||
"module": "news", "topicId": news_id, "commentId": comment_id,
|
||||
"commentUserId": uid, "content": content,
|
||||
"fromUserName": user.nickname, "avatar": user.avatar_url or "",
|
||||
"module": "news",
|
||||
"modules": "news",
|
||||
"topicId": news_id,
|
||||
"commentId": parent_comment_id,
|
||||
"commentUserId": parent_user_id,
|
||||
"replyId": reply_target_id,
|
||||
"toUserId": reply_target_user_id,
|
||||
"replyType": "2" if reply_to else "1",
|
||||
"toUserName": reply_target_user_name,
|
||||
"content": content,
|
||||
"title": article_title or parent_comment.get("title") or "",
|
||||
"orgId": final_org_id,
|
||||
"fromUserName": from_user_name,
|
||||
"avatar": user.avatar_url or "",
|
||||
}
|
||||
return await self._json_post(f"{biz}/message/comment/reply", self._bearer(sess["token"]), body)
|
||||
success, err, reply_id = await self._json_post_with_record_id(
|
||||
f"{biz}/message/comment/reply", self._bearer(sess["token"]), body
|
||||
)
|
||||
if success and not reply_id:
|
||||
reply_id, _ = await self.find_reply_id(
|
||||
db, user, news_id,
|
||||
content=content,
|
||||
parent_comment_id=parent_comment_id,
|
||||
)
|
||||
return success, err, reply_id
|
||||
|
||||
async def _get_article_org_id(self, db, token: str, news_id: str) -> str:
|
||||
"""从文章详情补齐 H5 评论/回复所需的组织上下文。"""
|
||||
if not news_id:
|
||||
return ""
|
||||
biz = await self._biz_url(db)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as c:
|
||||
r = await c.get(f"{biz}/news/{news_id}", headers=self._bearer(token))
|
||||
if r.status_code != 200:
|
||||
return ""
|
||||
data = (r.json().get("data") or {})
|
||||
return str(
|
||||
data.get("orgId")
|
||||
or data.get("alumnusId")
|
||||
or data.get("publishOrgId")
|
||||
or data.get("initialPublishOrgId")
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
async def get_comments(self, db, user, news_id) -> list:
|
||||
data = await self.get_comments_payload(db, user, news_id)
|
||||
if isinstance(data, dict):
|
||||
rows = data.get("data") or data.get("records") or data.get("list") or data.get("rows")
|
||||
return rows if isinstance(rows, list) else []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def get_comments_payload(self, db, user, news_id) -> Any:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return []
|
||||
biz = await self._biz_url(db)
|
||||
cfg = await self._client(db)
|
||||
try:
|
||||
params = self._build_form({"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}, cfg)
|
||||
params = {"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{biz}/message/comment", headers=self._bearer(sess["token"]), params=params)
|
||||
r = await c.get(
|
||||
f"{self._api_root(biz)}/interaction/open/selectInteractionCommentList",
|
||||
headers=self._bearer(sess["token"]),
|
||||
params=params,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("data", {}).get("data") or []
|
||||
return r.json().get("data") or []
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def find_comment_id(self, db, user, news_id: str, content: str = "") -> tuple[str, int | None]:
|
||||
"""Best-effort lookup for old records created before platform IDs were saved."""
|
||||
sess = await get_session(user.id)
|
||||
my_uid = str(sess.get("platform_uid", "")) if sess else ""
|
||||
payload = await self.get_comments_payload(db, user, news_id)
|
||||
comment_count = None
|
||||
comments = payload if isinstance(payload, list) else []
|
||||
if isinstance(payload, dict):
|
||||
try:
|
||||
comment_count = int(payload.get("commentCount"))
|
||||
except (TypeError, ValueError):
|
||||
comment_count = None
|
||||
comments = (
|
||||
payload.get("data")
|
||||
or payload.get("records")
|
||||
or payload.get("list")
|
||||
or payload.get("rows")
|
||||
or []
|
||||
)
|
||||
if not isinstance(comments, list):
|
||||
comments = []
|
||||
|
||||
target_content = (content or "").strip()
|
||||
for item in comments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
author_id = str(item.get("userId") or item.get("createUser") or item.get("commentUserId") or "")
|
||||
item_content = (item.get("content") or "").strip()
|
||||
if my_uid and author_id and author_id != my_uid:
|
||||
continue
|
||||
if target_content and item_content != target_content:
|
||||
continue
|
||||
comment_id = str(item.get("id") or item.get("commentId") or item.get("messageId") or "")
|
||||
if comment_id:
|
||||
return comment_id, comment_count
|
||||
return "", comment_count
|
||||
|
||||
async def find_reply_id(
|
||||
self, db, user, news_id: str, content: str = "", parent_comment_id: str = ""
|
||||
) -> tuple[str, int | None]:
|
||||
"""Best-effort lookup for reply records created before platform IDs were saved."""
|
||||
sess = await get_session(user.id)
|
||||
my_uid = str(sess.get("platform_uid", "")) if sess else ""
|
||||
payload = await self.get_comments_payload(db, user, news_id)
|
||||
reply_count = None
|
||||
comments = payload if isinstance(payload, list) else []
|
||||
if isinstance(payload, dict):
|
||||
for count_key in ("replyCount", "commentCount", "total"):
|
||||
try:
|
||||
if payload.get(count_key) is not None:
|
||||
reply_count = int(payload.get(count_key))
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
reply_count = None
|
||||
comments = (
|
||||
payload.get("data")
|
||||
or payload.get("records")
|
||||
or payload.get("list")
|
||||
or payload.get("rows")
|
||||
or []
|
||||
)
|
||||
if not isinstance(comments, list):
|
||||
comments = []
|
||||
|
||||
target_content = (content or "").strip()
|
||||
target_parent_id = str(parent_comment_id or "")
|
||||
for item in comments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
current_parent_id = str(item.get("id") or item.get("commentId") or "")
|
||||
if target_parent_id and current_parent_id != target_parent_id:
|
||||
continue
|
||||
replies = (
|
||||
item.get("respList")
|
||||
or item.get("replyList")
|
||||
or item.get("children")
|
||||
or item.get("replies")
|
||||
or []
|
||||
)
|
||||
if not isinstance(replies, list):
|
||||
continue
|
||||
for reply in replies:
|
||||
if not isinstance(reply, dict):
|
||||
continue
|
||||
author_id = str(
|
||||
reply.get("fromUserId")
|
||||
or reply.get("createUser")
|
||||
or reply.get("userId")
|
||||
or reply.get("commentUserId")
|
||||
or ""
|
||||
)
|
||||
reply_content = (reply.get("content") or "").strip()
|
||||
if my_uid and author_id and author_id != my_uid:
|
||||
continue
|
||||
if target_content and reply_content != target_content:
|
||||
continue
|
||||
reply_id = str(reply.get("id") or reply.get("replyId") or reply.get("commentId") or "")
|
||||
if reply_id:
|
||||
return reply_id, reply_count
|
||||
return "", reply_count
|
||||
|
||||
async def like_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
@@ -825,13 +1087,41 @@ class NewsPlatformService:
|
||||
return True, ""
|
||||
return False, f"HTTP {resp.status_code}"
|
||||
|
||||
@staticmethod
|
||||
def _extract_record_id(data: Any) -> str:
|
||||
if data is None:
|
||||
return ""
|
||||
if isinstance(data, (str, int)):
|
||||
return str(data)
|
||||
if isinstance(data, dict):
|
||||
for key in ("id", "replyId", "commentId", "messageId", "recordId"):
|
||||
if data.get(key):
|
||||
return str(data[key])
|
||||
for key in ("data", "record", "reply", "comment", "message"):
|
||||
nested = NewsPlatformService._extract_record_id(data.get(key))
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
|
||||
async def _json_post(self, url, headers, body) -> tuple[bool, str]:
|
||||
success, err, _ = await self._json_post_with_record_id(url, headers, body)
|
||||
return success, err
|
||||
|
||||
async def _json_post_with_record_id(self, url, headers, body) -> tuple[bool, str, str]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.post(url, json=body, headers=headers)
|
||||
return self._ok(r)
|
||||
if r.status_code not in [200, 201]:
|
||||
return False, f"HTTP {r.status_code}", ""
|
||||
try:
|
||||
d = r.json()
|
||||
except Exception:
|
||||
return True, "", ""
|
||||
if d.get("code") in [0, 200]:
|
||||
return True, "", self._extract_record_id(d.get("data"))
|
||||
return False, d.get("message") or "业务失败", ""
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return False, str(e), ""
|
||||
|
||||
async def _write_login_log(self, db, user, action, session_id=None, error_msg=None):
|
||||
try:
|
||||
@@ -898,6 +1188,25 @@ class NewsPlatformService:
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def cancel_reply(self, db, user, news_id: str, reply_id: str) -> tuple[bool, str]:
|
||||
"""DELETE /message/comment/reply/{topicId}/{id} 删除评论回复"""
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
biz = await self._biz_url(db)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.delete(
|
||||
f"{biz}/message/comment/reply/{news_id}/{reply_id}",
|
||||
headers=self._bearer(sess["token"]),
|
||||
)
|
||||
d = r.json()
|
||||
if d.get("code") in [0, 200]:
|
||||
return True, ""
|
||||
return False, d.get("message", "删除回复失败")
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def cancel_collect(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]:
|
||||
"""取消收藏(复用取消点赞接口)"""
|
||||
return await self.cancel_like(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""调度服务 - 定时自动互动、会话校验"""
|
||||
import random
|
||||
import asyncio
|
||||
from datetime import datetime, date
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select, update, func
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.logger import logger
|
||||
from app.models import VirtualUser, UserPersonality, InteractionRecord, SystemConfig
|
||||
from app.models import VirtualUser, UserPersonality, InteractionRecord, PendingReplyTask, SystemConfig
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
@@ -23,6 +23,12 @@ class SchedulerService:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
logger.info("⚡ 立即触发互动任务")
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5"))
|
||||
except (TypeError, ValueError):
|
||||
max_concurrent = 5
|
||||
max_concurrent = max(1, max_concurrent)
|
||||
|
||||
result_r = await session.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.status == 2,
|
||||
@@ -31,13 +37,62 @@ class SchedulerService:
|
||||
)
|
||||
users = result_r.scalars().all()
|
||||
if not users:
|
||||
return {"message": "没有已登录的用户", "triggered": 0}
|
||||
return {
|
||||
"message": "没有已登录的用户",
|
||||
"requested_concurrency": max_concurrent,
|
||||
"attempted_count": 0,
|
||||
"success_count": 0,
|
||||
"skipped_count": 0,
|
||||
"failed_count": 0,
|
||||
"triggered": 0,
|
||||
"users": [],
|
||||
"results": [],
|
||||
}
|
||||
import random
|
||||
selected = random.sample(users, min(5, len(users)))
|
||||
selected = random.sample(users, min(max_concurrent, len(users)))
|
||||
import asyncio
|
||||
tasks = [self._execute_user_interaction(u.id) for u in selected]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return {"triggered": len(selected), "users": [u.account for u in selected]}
|
||||
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
results = []
|
||||
success_count = skipped_count = failed_count = 0
|
||||
for user, outcome in zip(selected, raw_results):
|
||||
if isinstance(outcome, Exception):
|
||||
item = {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "failed",
|
||||
"reason": str(outcome),
|
||||
"interactions": [],
|
||||
}
|
||||
failed_count += 1
|
||||
else:
|
||||
item = outcome or {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_result",
|
||||
"interactions": [],
|
||||
}
|
||||
status = item.get("status")
|
||||
if status == "success":
|
||||
success_count += 1
|
||||
elif status == "failed":
|
||||
failed_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
results.append(item)
|
||||
|
||||
return {
|
||||
"requested_concurrency": max_concurrent,
|
||||
"attempted_count": len(selected),
|
||||
"success_count": success_count,
|
||||
"skipped_count": skipped_count,
|
||||
"failed_count": failed_count,
|
||||
"triggered": len(selected),
|
||||
"users": [u.account for u in selected],
|
||||
"results": results,
|
||||
}
|
||||
|
||||
async def start(self):
|
||||
if self._running:
|
||||
@@ -52,6 +107,11 @@ class SchedulerService:
|
||||
self._run_interactions, IntervalTrigger(minutes=5),
|
||||
id="run_interactions", replace_existing=True
|
||||
)
|
||||
# 待发送回复队列:持久化延迟回复,后端重启后可继续发送
|
||||
self.scheduler.add_job(
|
||||
self._process_pending_reply_tasks, IntervalTrigger(seconds=30),
|
||||
id="process_pending_reply_tasks", replace_existing=True
|
||||
)
|
||||
# 每日零点重置计数
|
||||
self.scheduler.add_job(
|
||||
self._daily_reset, "cron", hour=16, minute=0, # 北京时间 00:00 = UTC 16:00
|
||||
@@ -221,7 +281,13 @@ class SchedulerService:
|
||||
user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user or user.status != 2:
|
||||
return
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"account": getattr(user, "account", ""),
|
||||
"status": "skipped",
|
||||
"reason": "user_not_logged_in",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# 检查今日评论限额
|
||||
can_comment = True
|
||||
@@ -255,7 +321,13 @@ class SchedulerService:
|
||||
f"用户 {user.account} 获取新闻列表为空 "
|
||||
f"(orgId={await news_service._cfg(db, 'platform_org_id', '')})"
|
||||
)
|
||||
return
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_articles",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# ── 文章去重 + 热度加权选取 ─────────────────────────────────
|
||||
# 查询今日已互动过的文章(所有类型),避免重复互动同一篇
|
||||
@@ -307,7 +379,14 @@ class SchedulerService:
|
||||
article_org_id = str(article.get("orgId") or "")
|
||||
|
||||
if not news_id:
|
||||
return
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "missing_news_id",
|
||||
"interactions": [],
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
# 读取互动概率
|
||||
comment_prob = float(await self._get_config_from_db(db, "comment_probability", "0.4"))
|
||||
@@ -317,6 +396,7 @@ class SchedulerService:
|
||||
forward_prob = float(await self._get_config_from_db(db, "forward_probability", "0.15"))
|
||||
|
||||
interactions_done = []
|
||||
action_failures = []
|
||||
|
||||
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
|
||||
await news_service.read_news(db, user, news_id)
|
||||
@@ -331,6 +411,8 @@ class SchedulerService:
|
||||
if success:
|
||||
interactions_done.append("like")
|
||||
await self._incr_total(db, user_id)
|
||||
else:
|
||||
action_failures.append({"type": "like", "error": err})
|
||||
|
||||
# ③ 收藏(每篇文章每用户每天只收藏一次)
|
||||
if "collect" not in done_on_this and random.random() < collect_prob:
|
||||
@@ -338,6 +420,8 @@ class SchedulerService:
|
||||
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
|
||||
if success:
|
||||
interactions_done.append("collect")
|
||||
else:
|
||||
action_failures.append({"type": "collect", "error": err})
|
||||
|
||||
# ④ 转发(每篇文章每用户每天只转发一次)
|
||||
if "forward" not in done_on_this and random.random() < forward_prob:
|
||||
@@ -346,109 +430,433 @@ class SchedulerService:
|
||||
if success:
|
||||
interactions_done.append("forward")
|
||||
await self._incr_total(db, user_id)
|
||||
else:
|
||||
action_failures.append({"type": "forward", "error": err})
|
||||
|
||||
# ⑤ 评论/回复逻辑(去重:已评论过的文章改为回复他人评论)
|
||||
# ⑤ 评论/回复逻辑:评论和回复互相独立,未评论过文章也可以回复他人评论
|
||||
if can_comment and personality:
|
||||
style_prompt = personality.comment_style_prompt or ""
|
||||
safe_word_max = min(personality.word_count_max, 80)
|
||||
|
||||
if already_commented_this:
|
||||
# 已评论过此文章 → 改为回复其他用户的评论(虚拟用户互动)
|
||||
if random.random() < reply_prob:
|
||||
existing = await news_service.get_comments(db, user, news_id)
|
||||
if existing:
|
||||
# 优先回复虚拟用户的评论(促进互动),过滤掉自己的评论
|
||||
from app.core.redis_client import get_session as _gs
|
||||
my_sess = await _gs(user.id)
|
||||
my_uid = my_sess.get("platform_uid", "") if my_sess else ""
|
||||
others = [c for c in existing
|
||||
if str(c.get("userId") or c.get("createUser") or "") != my_uid]
|
||||
if others:
|
||||
target = random.choice(others)
|
||||
cid = str(target.get("id") or target.get("commentId") or "")
|
||||
parent_content = target.get("content") or ""
|
||||
if cid:
|
||||
reply_text, r_tokens = await ai_service.generate_reply(
|
||||
db, news_title, parent_content,
|
||||
style_prompt,
|
||||
personality.word_count_min,
|
||||
safe_word_max
|
||||
)
|
||||
if reply_text:
|
||||
r_ok, r_err = await news_service.post_reply(
|
||||
db, user, news_id, cid, reply_text
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "reply",
|
||||
reply_text, r_tokens, r_ok, r_err,
|
||||
parent_comment_id=cid
|
||||
)
|
||||
if r_ok:
|
||||
interactions_done.append("reply")
|
||||
logger.info(f"💬 {user.account} 回复了已评论文章的评论(去重逻辑)")
|
||||
else:
|
||||
# 未评论过此文章 → 正常发评论
|
||||
if random.random() < comment_prob:
|
||||
comment_text, tokens = await ai_service.generate_comment(
|
||||
db, news_title, news_content,
|
||||
style_prompt, personality.word_count_min, safe_word_max
|
||||
)
|
||||
if comment_text:
|
||||
success, err = await news_service.post_comment(
|
||||
db, user, news_id, news_title, comment_text,
|
||||
news_author_id=news_author, org_id=article_org_id
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "comment",
|
||||
comment_text, tokens, success, err
|
||||
)
|
||||
if success:
|
||||
interactions_done.append("comment")
|
||||
await db.execute(
|
||||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||
today_comment_count=VirtualUser.today_comment_count + 1,
|
||||
total_interactions=VirtualUser.total_interactions + 1,
|
||||
last_interact_at=datetime.now()
|
||||
)
|
||||
)
|
||||
if random.random() < reply_prob:
|
||||
reply_actions, reply_failures = await self._run_reply_interaction_chain(
|
||||
db=db,
|
||||
starter=user,
|
||||
starter_personality=personality,
|
||||
news_service=news_service,
|
||||
ai_service=ai_service,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
style_prompt=style_prompt,
|
||||
safe_word_max=safe_word_max,
|
||||
)
|
||||
interactions_done.extend(reply_actions)
|
||||
action_failures.extend(reply_failures)
|
||||
|
||||
# ⑥ 评论成功后,随机回复其他用户的评论(互动链)
|
||||
if random.random() < reply_prob:
|
||||
existing = await news_service.get_comments(db, user, news_id)
|
||||
if existing:
|
||||
from app.core.redis_client import get_session as _gs2
|
||||
my_sess2 = await _gs2(user.id)
|
||||
my_uid2 = my_sess2.get("platform_uid", "") if my_sess2 else ""
|
||||
others2 = [c for c in existing
|
||||
if str(c.get("userId") or c.get("createUser") or "") != my_uid2]
|
||||
if others2:
|
||||
target2 = random.choice(others2)
|
||||
cid2 = str(target2.get("id") or target2.get("commentId") or "")
|
||||
parent_content2 = target2.get("content") or ""
|
||||
if cid2:
|
||||
reply_text2, r_tokens2 = await ai_service.generate_reply(
|
||||
db, news_title, parent_content2,
|
||||
style_prompt,
|
||||
personality.word_count_min,
|
||||
safe_word_max
|
||||
)
|
||||
if reply_text2:
|
||||
r_ok2, r_err2 = await news_service.post_reply(
|
||||
db, user, news_id, cid2, reply_text2
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "reply",
|
||||
reply_text2, r_tokens2, r_ok2, r_err2,
|
||||
parent_comment_id=cid2
|
||||
)
|
||||
if r_ok2:
|
||||
interactions_done.append("reply")
|
||||
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
|
||||
if not already_commented_this and random.random() < comment_prob:
|
||||
comment_text, tokens = await ai_service.generate_comment(
|
||||
db, news_title, news_content,
|
||||
style_prompt, personality.word_count_min, safe_word_max
|
||||
)
|
||||
if comment_text:
|
||||
success, err, comment_record_id = await news_service.post_comment_with_record_id(
|
||||
db, user, news_id, news_title, comment_text,
|
||||
news_author_id=news_author, org_id=article_org_id
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "comment",
|
||||
comment_text, tokens, success, err,
|
||||
platform_record_id=comment_record_id,
|
||||
)
|
||||
if success:
|
||||
interactions_done.append("comment")
|
||||
await db.execute(
|
||||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||
today_comment_count=VirtualUser.today_comment_count + 1,
|
||||
total_interactions=VirtualUser.total_interactions + 1,
|
||||
last_interact_at=datetime.now()
|
||||
)
|
||||
)
|
||||
else:
|
||||
action_failures.append({"type": "comment", "error": err})
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"👤 {user.account} 互动完成: {interactions_done} [新闻: {news_title[:20]}]")
|
||||
if interactions_done:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "success",
|
||||
"reason": "",
|
||||
"interactions": interactions_done,
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
if action_failures:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "failed",
|
||||
"reason": "; ".join(
|
||||
f"{item['type']}:{item['error'] or 'unknown'}" for item in action_failures
|
||||
),
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_actions_triggered",
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {user_id} 互动异常: {e}")
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"account": "",
|
||||
"status": "failed",
|
||||
"reason": str(e),
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
async def _run_reply_interaction_chain(
|
||||
self,
|
||||
db,
|
||||
starter: VirtualUser,
|
||||
starter_personality,
|
||||
news_service,
|
||||
ai_service,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
style_prompt: str,
|
||||
safe_word_max: int,
|
||||
) -> tuple[list[str], list[dict]]:
|
||||
"""主动回复评论,并按概率安排后续延迟回应。"""
|
||||
from app.core.redis_client import get_session
|
||||
|
||||
actions: list[str] = []
|
||||
failures: list[dict] = []
|
||||
starter_sess = await get_session(starter.id)
|
||||
starter_uid = str(starter_sess.get("platform_uid") or "") if starter_sess else ""
|
||||
comments = await news_service.get_comments(db, starter, news_id)
|
||||
if not comments:
|
||||
return actions, failures
|
||||
|
||||
candidates = [
|
||||
c for c in comments
|
||||
if str(c.get("id") or c.get("commentId") or "")
|
||||
and (c.get("content") or "").strip()
|
||||
and str(c.get("userId") or c.get("createUser") or "") != starter_uid
|
||||
]
|
||||
if not candidates:
|
||||
return actions, failures
|
||||
|
||||
parent_comment = random.choice(candidates)
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
root_content = (parent_comment.get("content") or "").strip()
|
||||
context = f"准备回复这条评论:{root_content}"
|
||||
reply_text, reply_tokens = await ai_service.generate_thread_reply(
|
||||
db, news_title, root_content, context,
|
||||
style_prompt,
|
||||
starter_personality.word_count_min,
|
||||
safe_word_max,
|
||||
)
|
||||
if not reply_text:
|
||||
return actions, failures
|
||||
|
||||
ok, err, reply_id = await news_service.post_reply_with_record_id(
|
||||
db, starter, news_id, parent_comment_id, reply_text,
|
||||
parent_comment=parent_comment,
|
||||
article_title=news_title,
|
||||
org_id=article_org_id,
|
||||
)
|
||||
await self._save_record(
|
||||
db, starter, news_id, news_title, "reply",
|
||||
reply_text, reply_tokens, ok, err,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=reply_id,
|
||||
)
|
||||
if not ok:
|
||||
failures.append({"type": "reply", "error": err})
|
||||
return actions, failures
|
||||
|
||||
actions.append("reply")
|
||||
await self._incr_total(db, starter.id)
|
||||
logger.info(f"💬 {starter.account} 回复了文章评论")
|
||||
|
||||
starter_reply = {
|
||||
"id": reply_id,
|
||||
"content": reply_text,
|
||||
"createUser": starter_uid,
|
||||
"fromUserName": starter.real_name or starter.nickname or starter.account,
|
||||
}
|
||||
|
||||
chain_probability = await self._get_float_config(db, "reply_chain_probability", 0.3)
|
||||
delay_min = await self._get_int_config(db, "reply_chain_delay_min_seconds", 30)
|
||||
delay_max = await self._get_int_config(db, "reply_chain_delay_max_seconds", 7200)
|
||||
if delay_max < delay_min:
|
||||
delay_max = delay_min
|
||||
|
||||
# 评论作者如果也是当前系统里的已登录虚拟用户,按概率安排稍后回复这条回复。
|
||||
parent_author_uid = str(parent_comment.get("createUser") or parent_comment.get("userId") or "")
|
||||
parent_author = await self._get_logged_in_user_by_platform_uid(db, parent_author_uid)
|
||||
if (
|
||||
parent_author
|
||||
and parent_author.id != starter.id
|
||||
and random.random() < chain_probability
|
||||
):
|
||||
delay_seconds = random.randint(delay_min, delay_max)
|
||||
await self._enqueue_pending_reply_task(
|
||||
db=db,
|
||||
delay_seconds=delay_seconds,
|
||||
actor_id=parent_author.id,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=starter_reply,
|
||||
root_content=root_content,
|
||||
context=f"对方刚回复了你的评论:{reply_text}",
|
||||
next_actor_id=starter.id,
|
||||
next_probability=chain_probability,
|
||||
next_delay_min=delay_min,
|
||||
next_delay_max=delay_max,
|
||||
)
|
||||
logger.info(
|
||||
f"⏳ {parent_author.account} 已进入待发送回复队列,延迟 {delay_seconds}s 后发送"
|
||||
)
|
||||
|
||||
return actions, failures
|
||||
|
||||
async def _enqueue_pending_reply_task(
|
||||
self,
|
||||
db,
|
||||
delay_seconds: int,
|
||||
actor_id: int,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
parent_comment: dict,
|
||||
reply_to: dict,
|
||||
root_content: str,
|
||||
context: str,
|
||||
next_actor_id: int | None = None,
|
||||
next_probability: float = 0.3,
|
||||
next_delay_min: int = 30,
|
||||
next_delay_max: int = 7200,
|
||||
):
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
db.add(PendingReplyTask(
|
||||
actor_user_id=actor_id,
|
||||
next_actor_user_id=next_actor_id,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
parent_comment_id=parent_comment_id,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
root_content=root_content,
|
||||
context=context,
|
||||
next_probability=next_probability,
|
||||
next_delay_min_seconds=next_delay_min,
|
||||
next_delay_max_seconds=next_delay_max,
|
||||
status=0,
|
||||
attempts=0,
|
||||
scheduled_at=datetime.now() + timedelta(seconds=max(0, delay_seconds)),
|
||||
))
|
||||
|
||||
async def _process_pending_reply_tasks(self):
|
||||
from app.services.news_service import news_service
|
||||
from app.services.ai_service import ai_service
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
update(PendingReplyTask)
|
||||
.where(
|
||||
PendingReplyTask.status == 1,
|
||||
PendingReplyTask.locked_at < now - timedelta(minutes=10),
|
||||
)
|
||||
.values(status=0, last_error="发送中超时,重新入队")
|
||||
)
|
||||
result = await db.execute(
|
||||
select(PendingReplyTask)
|
||||
.where(
|
||||
PendingReplyTask.status == 0,
|
||||
PendingReplyTask.scheduled_at <= now,
|
||||
)
|
||||
.order_by(PendingReplyTask.scheduled_at.asc())
|
||||
.limit(10)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
for task in tasks:
|
||||
await self._process_pending_reply_task(db, task, news_service, ai_service)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"待发送回复队列处理异常: {e}")
|
||||
|
||||
async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service):
|
||||
task.status = 1
|
||||
task.locked_at = datetime.now()
|
||||
task.attempts = (task.attempts or 0) + 1
|
||||
await db.flush()
|
||||
|
||||
actor = await self._get_user_by_id(db, task.actor_user_id)
|
||||
if not actor or actor.status != 2 or actor.is_enabled != 1:
|
||||
task.status = 3
|
||||
task.last_error = "用户未登录或已禁用"
|
||||
return
|
||||
|
||||
reply_result = await self._post_contextual_reply(
|
||||
db=db,
|
||||
actor=actor,
|
||||
news_service=news_service,
|
||||
ai_service=ai_service,
|
||||
news_id=task.news_id,
|
||||
news_title=task.news_title or "",
|
||||
article_org_id=task.article_org_id or "",
|
||||
parent_comment=task.parent_comment or {},
|
||||
reply_to=task.reply_to or {},
|
||||
root_content=task.root_content or "",
|
||||
context=task.context or "",
|
||||
)
|
||||
if not reply_result["ok"]:
|
||||
task.status = 3 if task.attempts >= 3 else 0
|
||||
task.last_error = reply_result["error"] or "生成或发送回复失败"
|
||||
if task.status == 0:
|
||||
task.scheduled_at = datetime.now() + timedelta(minutes=5)
|
||||
logger.warning(f"待发送回复失败 task_id={task.id} user={actor.account}: {task.last_error}")
|
||||
return
|
||||
|
||||
task.status = 2
|
||||
task.sent_at = datetime.now()
|
||||
task.last_error = None
|
||||
await self._incr_total(db, actor.id)
|
||||
logger.info(f"💬 {actor.account} 发送了待发送回复 task_id={task.id}")
|
||||
|
||||
if task.next_actor_user_id and random.random() < float(task.next_probability or 0.3):
|
||||
delay_min = int(task.next_delay_min_seconds or 30)
|
||||
delay_max = max(delay_min, int(task.next_delay_max_seconds or 7200))
|
||||
next_delay = random.randint(delay_min, delay_max)
|
||||
await self._enqueue_pending_reply_task(
|
||||
db=db,
|
||||
delay_seconds=next_delay,
|
||||
actor_id=task.next_actor_user_id,
|
||||
news_id=task.news_id,
|
||||
news_title=task.news_title or "",
|
||||
article_org_id=task.article_org_id or "",
|
||||
parent_comment=task.parent_comment or {},
|
||||
reply_to=reply_result["reply"],
|
||||
root_content=task.root_content or "",
|
||||
context=(
|
||||
f"原评论:{task.root_content or ''}\n"
|
||||
f"上一条回复:{(task.reply_to or {}).get('content') or ''}\n"
|
||||
f"对方回应:{reply_result['content']}"
|
||||
),
|
||||
next_actor_id=None,
|
||||
next_probability=float(task.next_probability or 0.3),
|
||||
next_delay_min=delay_min,
|
||||
next_delay_max=delay_max,
|
||||
)
|
||||
logger.info(f"⏳ 已入队继续回复,延迟 {next_delay}s 后发送")
|
||||
|
||||
async def _post_contextual_reply(
|
||||
self,
|
||||
db,
|
||||
actor: VirtualUser,
|
||||
news_service,
|
||||
ai_service,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
parent_comment: dict,
|
||||
reply_to: dict,
|
||||
root_content: str,
|
||||
context: str,
|
||||
) -> dict:
|
||||
personality = await self._get_user_personality(db, actor.id)
|
||||
style_prompt = personality.comment_style_prompt if personality else ""
|
||||
word_min = personality.word_count_min if personality else 15
|
||||
word_max = min(personality.word_count_max, 80) if personality else 60
|
||||
content, tokens = await ai_service.generate_thread_reply(
|
||||
db, news_title, root_content, context,
|
||||
style_prompt, word_min, word_max,
|
||||
)
|
||||
if not content:
|
||||
return {"ok": False, "error": "", "reply": {}, "content": ""}
|
||||
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
ok, err, reply_id = await news_service.post_reply_with_record_id(
|
||||
db, actor, news_id, parent_comment_id, content,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
article_title=news_title,
|
||||
org_id=article_org_id,
|
||||
)
|
||||
await self._save_record(
|
||||
db, actor, news_id, news_title, "reply",
|
||||
content, tokens, ok, err,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=reply_id,
|
||||
)
|
||||
from app.core.redis_client import get_session
|
||||
sess = await get_session(actor.id)
|
||||
actor_uid = str(sess.get("platform_uid") or "") if sess else ""
|
||||
return {
|
||||
"ok": ok,
|
||||
"error": "" if ok else err,
|
||||
"content": content,
|
||||
"reply": {
|
||||
"id": reply_id,
|
||||
"content": content,
|
||||
"createUser": actor_uid,
|
||||
"fromUserName": actor.real_name or actor.nickname or actor.account,
|
||||
},
|
||||
}
|
||||
|
||||
async def _get_logged_in_user_by_platform_uid(self, db, platform_uid: str) -> VirtualUser | None:
|
||||
if not platform_uid:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.platform_uid == platform_uid,
|
||||
VirtualUser.status == 2,
|
||||
VirtualUser.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_by_id(self, db, user_id: int) -> VirtualUser | None:
|
||||
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_personality(self, db, user_id: int):
|
||||
result = await db.execute(
|
||||
select(UserPersonality).where(UserPersonality.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_float_config(self, db, key: str, default: float) -> float:
|
||||
try:
|
||||
return float(await self._get_config_from_db(db, key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def _get_int_config(self, db, key: str, default: int) -> int:
|
||||
try:
|
||||
return int(float(await self._get_config_from_db(db, key, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def _incr_total(self, db, user_id: int):
|
||||
await db.execute(
|
||||
@@ -511,4 +919,4 @@ class SchedulerService:
|
||||
logger.info("每日计数重置完成")
|
||||
|
||||
|
||||
scheduler_service = SchedulerService()
|
||||
scheduler_service = SchedulerService()
|
||||
|
||||
@@ -230,7 +230,7 @@ class StatsService:
|
||||
"comment": "评论", "reply": "回复", "like": "点赞",
|
||||
"collect": "收藏", "forward": "转发"
|
||||
}
|
||||
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败"}
|
||||
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败", 3: "已取消"}
|
||||
|
||||
items = []
|
||||
for r in records:
|
||||
|
||||
6
digital-avatar-app/.dockerignore
Normal file
6
digital-avatar-app/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
.env
|
||||
*.log
|
||||
backend/
|
||||
4
digital-avatar-app/.gitignore
vendored
Normal file
4
digital-avatar-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
22
digital-avatar-app/Dockerfile
Normal file
22
digital-avatar-app/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# 构建阶段:安装依赖并打包 H5
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
# 跳过 vue-tsc 类型检查直接打包(与本机已知 vue-tsc + Node 版本兼容问题无关,保证可构建)
|
||||
RUN npx vite build
|
||||
|
||||
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
|
||||
# 锁定 1.28-alpine:测试服务器 Docker 的 seccomp 拦截 pwrite 系统调用,
|
||||
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 80
|
||||
6
digital-avatar-app/backend/.dockerignore
Normal file
6
digital-avatar-app/backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
15
digital-avatar-app/backend/Dockerfile
Normal file
15
digital-avatar-app/backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 后端依赖(fastapi/uvicorn/sqlalchemy/pypdf/python-docx/openpyxl 等)均为纯 Python wheel,
|
||||
# 无需 gcc 等编译链,故跳过 apt 安装以加快构建并减小镜像体积。
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
49
digital-avatar-app/backend/database.py
Normal file
49
digital-avatar-app/backend/database.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_FILE = os.path.join(BASE_DIR, "avatar.db")
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{DB_FILE}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
import models
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 轻量迁移:为已存在的表补充新列(SQLite 不支持自动 ALTER,逐列尝试)
|
||||
_try_add_columns(
|
||||
("qa_pairs", "enabled", "BOOLEAN DEFAULT 1"),
|
||||
("knowledge_docs", "vectorized", "BOOLEAN DEFAULT 0"),
|
||||
("knowledge_docs", "embedding_model", "VARCHAR DEFAULT ''"),
|
||||
("knowledge_docs", "chunk_count", "INTEGER DEFAULT 0"),
|
||||
("knowledge_docs", "vectorized_at", "TIMESTAMP"),
|
||||
("avatars", "owner_id", "VARCHAR DEFAULT ''"),
|
||||
)
|
||||
|
||||
|
||||
def _try_add_columns(*cols):
|
||||
with engine.connect() as conn:
|
||||
for table, col, ddl in cols:
|
||||
try:
|
||||
conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# 列已存在(或全新库由 create_all 建好)则忽略
|
||||
pass
|
||||
220
digital-avatar-app/backend/models.py
Normal file
220
digital-avatar-app/backend/models.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, JSON, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
return dt.isoformat() if dt else None
|
||||
|
||||
|
||||
class Avatar(Base):
|
||||
__tablename__ = "avatars"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
owner_id = Column(String, default="", index=True) # 归属用户(会会 huihui_user_id);空=未归属/种子数据
|
||||
name = Column(String, nullable=False)
|
||||
display_name = Column(String, default="")
|
||||
description = Column(Text, default="")
|
||||
photo_url = Column(String, default="")
|
||||
emoji = Column(String, default="🤖")
|
||||
status = Column(String, default="active") # active | inactive | training
|
||||
token_balance = Column(Integer, default=0)
|
||||
config = Column(JSON, default=dict)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"ownerId": self.owner_id,
|
||||
"name": self.name,
|
||||
"displayName": self.display_name,
|
||||
"description": self.description,
|
||||
"photoUrl": self.photo_url,
|
||||
"emoji": self.emoji,
|
||||
"status": self.status,
|
||||
"tokenBalance": self.token_balance,
|
||||
"config": self.config or {},
|
||||
"createdAt": _iso(self.created_at),
|
||||
"updatedAt": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class Authorization(Base):
|
||||
__tablename__ = "authorizations"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
target_type = Column(String, default="user") # user | organization | application
|
||||
target_id = Column(String, default="")
|
||||
target_name = Column(String, default="")
|
||||
permissions = Column(JSON, default=list)
|
||||
status = Column(String, default="active") # active | inactive
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"targetType": self.target_type,
|
||||
"targetId": self.target_id,
|
||||
"targetName": self.target_name,
|
||||
"permissions": self.permissions or [],
|
||||
"status": self.status,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, default="")
|
||||
emoji = Column(String, default="🏢")
|
||||
org_type = Column(String, default="team") # team | company | community
|
||||
role = Column(String, default="admin") # admin | member | viewer
|
||||
member_count = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"emoji": self.emoji,
|
||||
"type": self.org_type,
|
||||
"role": self.role,
|
||||
"memberCount": self.member_count,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeDoc(Base):
|
||||
__tablename__ = "knowledge_docs"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
filename = Column(String, default="")
|
||||
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
||||
file_size = Column(Integer, default=0)
|
||||
file_url = Column(String, default="")
|
||||
status = Column(String, default="uploaded") # uploaded | parsing | ready
|
||||
vectorized = Column(Boolean, default=False) # 是否已向量化
|
||||
embedding_model = Column(String, default="") # 向量模型标识
|
||||
chunk_count = Column(Integer, default=0) # 切片数量
|
||||
vectorized_at = Column(DateTime) # 向量化时间
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"filename": self.filename,
|
||||
"fileType": self.file_type,
|
||||
"fileSize": self.file_size,
|
||||
"fileUrl": self.file_url,
|
||||
"status": self.status,
|
||||
"vectorized": bool(self.vectorized),
|
||||
"embeddingModel": self.embedding_model,
|
||||
"chunkCount": self.chunk_count,
|
||||
"vectorizedAt": _iso(self.vectorized_at),
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class QAPair(Base):
|
||||
__tablename__ = "qa_pairs"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
question = Column(Text, default="")
|
||||
answer = Column(Text, default="")
|
||||
enabled = Column(Boolean, default=True) # 是否启用(关闭后不参与作答)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"question": self.question,
|
||||
"answer": self.answer,
|
||||
"enabled": bool(self.enabled),
|
||||
"createdAt": _iso(self.created_at),
|
||||
"updatedAt": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeChunk(Base):
|
||||
__tablename__ = "knowledge_chunks"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
doc_id = Column(String, default="") # 关联 KnowledgeDoc.id
|
||||
avatar_id = Column(String, default="")
|
||||
content = Column(Text, default="") # 切片文本
|
||||
vector = Column(Text, default="") # JSON 编码的向量
|
||||
chunk_index = Column(Integer, default=0)
|
||||
embedding_model = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"docId": self.doc_id,
|
||||
"avatarId": self.avatar_id,
|
||||
"content": self.content,
|
||||
"chunkIndex": self.chunk_index,
|
||||
"embeddingModel": self.embedding_model,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class TokenAccount(Base):
|
||||
__tablename__ = "token_account"
|
||||
id = Column(Integer, primary_key=True)
|
||||
balance = Column(Integer, default=1250)
|
||||
|
||||
|
||||
class TokenPlan(Base):
|
||||
__tablename__ = "token_plans"
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, default="")
|
||||
amount = Column(Integer, default=0)
|
||||
price = Column(Float, default=0)
|
||||
badge = Column(String, default="")
|
||||
desc = Column(String, default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"amount": self.amount,
|
||||
"price": self.price,
|
||||
"badge": self.badge,
|
||||
"desc": self.desc,
|
||||
}
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
|
||||
|
||||
__tablename__ = "users"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
huihui_user_id = Column(String, default="", index=True) # 会会 userId(唯一标识)
|
||||
phone = Column(String, default="", index=True)
|
||||
nickname = Column(String, default="")
|
||||
avatar_url = Column(String, default="")
|
||||
huihui_token = Column(String, default="") # 会会 access_token
|
||||
app_token = Column(String, default="") # 本系统会话 token
|
||||
last_login_at = Column(DateTime)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"huihuiUserId": self.huihui_user_id,
|
||||
"phone": self.phone,
|
||||
"nickname": self.nickname,
|
||||
"avatarUrl": self.avatar_url,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"lastLoginAt": _iso(self.last_login_at),
|
||||
}
|
||||
9
digital-avatar-app/backend/requirements.txt
Normal file
9
digital-avatar-app/backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
python-multipart
|
||||
httpx
|
||||
pypdf
|
||||
python-docx
|
||||
openpyxl
|
||||
6
digital-avatar-app/backend/responses.py
Normal file
6
digital-avatar-app/backend/responses.py
Normal file
@@ -0,0 +1,6 @@
|
||||
def ok(data=None, message="success"):
|
||||
return {"code": 200, "message": message, "data": data}
|
||||
|
||||
|
||||
def fail(message="error", code=400):
|
||||
return {"code": code, "message": message, "data": None}
|
||||
0
digital-avatar-app/backend/routers/__init__.py
Normal file
0
digital-avatar-app/backend/routers/__init__.py
Normal file
32
digital-avatar-app/backend/routers/authorizations.py
Normal file
32
digital-avatar-app/backend/routers/authorizations.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Authorization
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["授权"])
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}/authorizations")
|
||||
def list_auth(avatar_id: str, db: Session = Depends(get_db)):
|
||||
# demo:返回全部授权(忽略具体 avatar 绑定,便于联调)
|
||||
items = db.query(Authorization).order_by(Authorization.created_at.desc()).all()
|
||||
return ok([a.to_dict() for a in items])
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/authorizations")
|
||||
def update_auth(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
auth_id = payload.get("id")
|
||||
if not auth_id:
|
||||
return fail("缺少授权 id", 400)
|
||||
a = db.query(Authorization).filter(Authorization.id == auth_id).first()
|
||||
if not a:
|
||||
return fail("授权不存在", 404)
|
||||
if "status" in payload:
|
||||
a.status = payload["status"]
|
||||
if "permissions" in payload:
|
||||
a.permissions = payload["permissions"]
|
||||
db.commit()
|
||||
items = db.query(Authorization).order_by(Authorization.created_at.desc()).all()
|
||||
return ok([x.to_dict() for x in items])
|
||||
95
digital-avatar-app/backend/routers/avatars.py
Normal file
95
digital-avatar-app/backend/routers/avatars.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, Body, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["分身"])
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
"""从 Authorization: Bearer <app_token> 解析当前登录用户"""
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
@router.get("/avatar")
|
||||
def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
# 仅返回当前登录用户自己的分身;未登录返回空,避免看到种子/他人数据
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
return ok({"data": [], "total": 0})
|
||||
q = db.query(Avatar).filter(Avatar.owner_id == user.huihui_user_id)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(Avatar.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return ok({"data": [a.to_dict() for a in items], "total": total})
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}")
|
||||
def get_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.post("/avatar")
|
||||
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _resolve_user(authorization, db)
|
||||
a = Avatar(
|
||||
owner_id=user.huihui_user_id if user else "",
|
||||
name=payload.get("name", "未命名分身"),
|
||||
display_name=payload.get("displayName", "") or payload.get("display_name", ""),
|
||||
description=payload.get("description", ""),
|
||||
photo_url=payload.get("photoUrl", "") or payload.get("photo_url", ""),
|
||||
emoji=payload.get("emoji", "🤖"),
|
||||
status=payload.get("status", "active"),
|
||||
token_balance=payload.get("tokenBalance", 0),
|
||||
config=payload.get("config", {}) or {},
|
||||
)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}")
|
||||
def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
mapping = {
|
||||
"displayName": "display_name",
|
||||
"photoUrl": "photo_url",
|
||||
"tokenBalance": "token_balance",
|
||||
}
|
||||
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
|
||||
if key in payload:
|
||||
col = mapping.get(key, key)
|
||||
setattr(a, col, payload[key])
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}")
|
||||
def delete_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
# 级联清理关联数据,避免孤儿记录
|
||||
db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete()
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete()
|
||||
db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete()
|
||||
db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete()
|
||||
db.delete(a)
|
||||
db.commit()
|
||||
return ok({"success": True})
|
||||
336
digital-avatar-app/backend/routers/huihui_auth.py
Normal file
336
digital-avatar-app/backend/routers/huihui_auth.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
会会短信验证码登录代理(真实开放平台对接)
|
||||
──────────────────────────────────────────────
|
||||
严格按会会开放平台 sign.js 签名范式:
|
||||
- 公共字段 appId/accessId/timestamp(12小时制hh)/signType/signVersion/accessSecret/nonce 合并业务参数
|
||||
- 过滤空值 → 字典序排序 → k=v& 拼接 → 末尾追加 accessSecret=secretKey → MD5/SHA256 大写
|
||||
- 认证服务基址 / appId / accessId / accessSecret / clientCode 走环境变量
|
||||
真实端点(来自 fat-open 网关 usercenter 的 Swagger):
|
||||
- 发送验证码:POST {BASE}{HUIHUI_SMS_SEND_PATH} 默认 /open/mobile/sms/code (query 参数)
|
||||
- 短信登录: POST {BASE}{HUIHUI_SMS_LOGIN_PATH} 默认 /open/login/token,loginType=code
|
||||
登录成功后建/链本地 users 表(按会会 userId 唯一),签发本系统 app_token 作为会话。
|
||||
无真实凭证时仍可走 DEV_MOCK 兜底联调。
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import random
|
||||
import string
|
||||
import hashlib
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# 会会网关按北京时间(Asia/Shanghai, UTC+8)校验时间戳,容器默认 UTC 会导致签名被拒。
|
||||
# 用固定 +8 偏移(不依赖 tzdata,slim 镜像缺 IANA 库时会抛 ZoneInfoNotFoundError)。
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["会会账号"])
|
||||
|
||||
# ── 会会开放平台配置(环境变量)──
|
||||
AUTH_BASE_URL = os.getenv("HUIHUI_AUTH_BASE_URL", "https://fat-open.99hui.com/api/usercenter")
|
||||
APP_ID = os.getenv("HUIHUI_APP_ID", "")
|
||||
ACCESS_ID = os.getenv("HUIHUI_ACCESS_ID", "")
|
||||
ACCESS_SECRET = os.getenv("HUIHUI_ACCESS_SECRET", "")
|
||||
CLIENT_CODE = os.getenv("HUIHUI_CLIENT_CODE", "")
|
||||
SMS_SEND_PATH = os.getenv("HUIHUI_SMS_SEND_PATH", "/open/mobile/sms/code")
|
||||
SMS_LOGIN_PATH = os.getenv("HUIHUI_SMS_LOGIN_PATH", "/open/login/token")
|
||||
|
||||
# 临时开发态:无真实会会凭证时,本地模拟短信收发,便于端到端联调。
|
||||
DEV_MOCK = os.getenv("HUIHUI_DEV_MOCK", "false").lower() in ("1", "true", "yes")
|
||||
_mock_codes: dict[str, tuple[str, float]] = {}
|
||||
_MOCK_TTL = 300
|
||||
|
||||
|
||||
# ── 签名体系(完全对应 sign.js)──
|
||||
def _get_nonce() -> str:
|
||||
# 与会会 sign.js 一致:base36 随机串
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
||||
|
||||
|
||||
def _get_timestamp() -> str:
|
||||
# 与会会 sign.js 一致:yyyyMMddHHmmss(24 小时制大写 HH)。
|
||||
# 实测:会会 common.format 走 24 小时制,用 12 小时制(%I)会被网关判"签名验证失败"。
|
||||
# 必须用北京时间,否则容器(UTC)生成的时间戳与会会网关校验窗口偏差 8h 被拒。
|
||||
return datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
|
||||
SIGN_KEY = "signature"
|
||||
SECRET_KEY = "accessSecret"
|
||||
keys = sorted(params.keys())
|
||||
parts = []
|
||||
for k in keys:
|
||||
if k in (SIGN_KEY, SECRET_KEY):
|
||||
continue
|
||||
v = params.get(k)
|
||||
if v is None or v == "" or v == []:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
continue
|
||||
parts.append(f"{k}={v}")
|
||||
sign_str = "&".join(parts) + f"&{SECRET_KEY}={secret_key}"
|
||||
if sign_type.upper() == "SHA256":
|
||||
return hashlib.sha256(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def _build_form(extra: dict) -> dict:
|
||||
sign_type = "MD5"
|
||||
sign_version = "1.0"
|
||||
secret = ACCESS_SECRET
|
||||
base = {
|
||||
"appId": APP_ID,
|
||||
"accessId": ACCESS_ID,
|
||||
"timestamp": _get_timestamp(),
|
||||
"signType": sign_type,
|
||||
"signVersion": sign_version,
|
||||
"accessSecret": secret,
|
||||
"nonce": _get_nonce(),
|
||||
}
|
||||
base.update(extra)
|
||||
signature = _make_sign(base, secret, sign_type) if secret else ""
|
||||
base["signature"] = signature
|
||||
base.pop("accessSecret", None) # 不发送密钥
|
||||
return base
|
||||
|
||||
|
||||
def _pick(d: dict, *keys, default=""):
|
||||
for k in keys:
|
||||
if d.get(k) not in (None, ""):
|
||||
return d[k]
|
||||
return default
|
||||
|
||||
|
||||
def _cfg_ready() -> bool:
|
||||
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
|
||||
|
||||
|
||||
def _call_huihui(path: str, params: dict, as_query: bool = False):
|
||||
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
|
||||
url = f"{AUTH_BASE_URL}{path}"
|
||||
with httpx.Client(timeout=30, follow_redirects=True) as c:
|
||||
if as_query:
|
||||
resp = c.post(url, params=params)
|
||||
else:
|
||||
resp = c.post(url, data=params)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return False, {"message": f"会会返回非JSON: {resp.text[:200]}"}, resp.status_code
|
||||
# 会会统一包装 {code, message, data}
|
||||
code = data.get("code")
|
||||
if resp.status_code == 200 and code in (0, 200, "0", "200"):
|
||||
return True, data, resp.status_code
|
||||
return False, data, resp.status_code
|
||||
|
||||
|
||||
@router.post("/huihui/sms/send")
|
||||
def sms_send(body: dict = Body(...)):
|
||||
"""请求会会发送短信验证码(真实开放平台 /open/mobile/sms/code)"""
|
||||
phone = (body.get("phone") or "").strip()
|
||||
if not phone or not phone.isdigit() or len(phone) != 11:
|
||||
return fail("请输入正确的 11 位手机号", 400)
|
||||
|
||||
# 临时开发态:本地模拟发码
|
||||
if DEV_MOCK and not _cfg_ready():
|
||||
code = str(random.randint(100000, 999999))
|
||||
_mock_codes[phone] = (code, datetime.now().timestamp() + _MOCK_TTL)
|
||||
return ok({"sent": True, "devCode": code, "dev": True})
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会短信服务未配置(缺少 HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET)", 500)
|
||||
|
||||
# /open/mobile/sms/code:mobile 与公共字段均走 query
|
||||
form = _build_form({"mobile": phone})
|
||||
ok_flag, data, status = _call_huihui(SMS_SEND_PATH, form, as_query=True)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"发送失败(HTTP {status})", 502)
|
||||
return ok({"sent": True})
|
||||
|
||||
|
||||
@router.post("/huihui/sms/login")
|
||||
def sms_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""短信验证码登录:调会会 /open/login/token(loginType=code) 换取 access_token + userId,落库并签发本系统会话"""
|
||||
phone = (body.get("phone") or "").strip()
|
||||
code = (body.get("code") or "").strip()
|
||||
if not phone or not code:
|
||||
return fail("手机号或验证码缺失", 400)
|
||||
|
||||
# 临时开发态:本地校验模拟码
|
||||
if DEV_MOCK and not _cfg_ready():
|
||||
rec = _mock_codes.get(phone)
|
||||
if not rec or rec[1] < datetime.now().timestamp():
|
||||
return fail("验证码已失效,请重新获取", 401)
|
||||
if rec[0] != code:
|
||||
return fail("验证码错误", 401)
|
||||
_mock_codes.pop(phone, None)
|
||||
return _issue_session(db, phone, {
|
||||
"userId": f"dev_{phone}",
|
||||
"nickname": f"会会用户{phone[-4:]}",
|
||||
"avatarUrl": "",
|
||||
"token": f"dev_token_{phone}",
|
||||
})
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
extra = {
|
||||
"username": phone,
|
||||
"password": code,
|
||||
"loginType": "code",
|
||||
"grantType": "password",
|
||||
"isRegister": "true",
|
||||
}
|
||||
if CLIENT_CODE:
|
||||
extra["clientCode"] = CLIENT_CODE
|
||||
form = _build_form(extra)
|
||||
ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"登录失败(HTTP {status})", 401)
|
||||
|
||||
raw = data.get("data") or {}
|
||||
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||
huihui_token = (
|
||||
_pick(raw, "accessToken", "access_token", "token")
|
||||
or _pick(user_info, "accessToken", "access_token", "token")
|
||||
)
|
||||
huihui_user_id = (
|
||||
_pick(raw, "openid", "userId", "uid", "openId", "id")
|
||||
or _pick(user_info, "openid", "userId", "uid", "openId", "id")
|
||||
)
|
||||
nickname = (
|
||||
_pick(raw, "nickName", "nickname", "name", "userName")
|
||||
or _pick(user_info, "nickName", "nickname", "name", "userName")
|
||||
)
|
||||
avatar_url = (
|
||||
_pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
)
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
return _issue_session(db, phone, {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/huihui/pwd/login")
|
||||
def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""账号密码登录:调会会 /open/login/token(loginType=password) 换取 access_token + userId,落库并签发本系统会话。
|
||||
与 news_service 既有对接完全一致:username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false。
|
||||
"""
|
||||
account = (body.get("account") or "").strip()
|
||||
password = (body.get("password") or "").strip()
|
||||
if not account or not password:
|
||||
return fail("账号或密码缺失", 400)
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
extra = {
|
||||
"username": account,
|
||||
"password": password,
|
||||
"loginType": "password",
|
||||
"grantType": "password",
|
||||
"isRegister": "false",
|
||||
}
|
||||
if CLIENT_CODE:
|
||||
extra["clientCode"] = CLIENT_CODE
|
||||
form = _build_form(extra)
|
||||
ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"登录失败(HTTP {status})", 401)
|
||||
|
||||
raw = data.get("data") or {}
|
||||
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||
huihui_token = (
|
||||
_pick(raw, "accessToken", "access_token", "token")
|
||||
or _pick(user_info, "accessToken", "access_token", "token")
|
||||
)
|
||||
huihui_user_id = (
|
||||
_pick(raw, "openid", "userId", "uid", "openId", "id")
|
||||
or _pick(user_info, "openid", "userId", "uid", "openId", "id")
|
||||
)
|
||||
nickname = (
|
||||
_pick(raw, "nickName", "nickname", "name", "userName")
|
||||
or _pick(user_info, "nickName", "nickname", "name", "userName")
|
||||
)
|
||||
avatar_url = (
|
||||
_pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
)
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
# 账号即手机号时记录,便于资料展示;非手机号(用户名)则不覆盖已有 phone
|
||||
phone = account if (account.isdigit() and len(account) == 11) else ""
|
||||
return _issue_session(db, phone, {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
})
|
||||
|
||||
|
||||
def _issue_session(db: Session, phone: str, info: dict):
|
||||
"""建/链本地用户并签发本系统会话 token"""
|
||||
huihui_user_id = info.get("userId", "")
|
||||
user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first()
|
||||
if not user:
|
||||
user = User(huihui_user_id=huihui_user_id)
|
||||
if phone:
|
||||
user.phone = phone
|
||||
if info.get("nickname"):
|
||||
user.nickname = info["nickname"]
|
||||
if info.get("avatarUrl"):
|
||||
user.avatar_url = info["avatarUrl"]
|
||||
user.huihui_token = info.get("token", "")
|
||||
user.app_token = uuid.uuid4().hex
|
||||
user.last_login_at = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return ok({
|
||||
"token": user.app_token,
|
||||
"user": user.to_dict(),
|
||||
"huihui": {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": info.get("nickname", ""),
|
||||
"avatarUrl": info.get("avatarUrl", ""),
|
||||
"token": info.get("token", ""),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.get("/huihui/me")
|
||||
def me(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""当前登录用户信息(Bearer app_token)"""
|
||||
if not authorization:
|
||||
return fail("未登录", 401)
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if not user:
|
||||
return fail("会话无效或已过期", 401)
|
||||
return ok(user.to_dict())
|
||||
|
||||
|
||||
@router.post("/huihui/logout")
|
||||
def logout(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""退出登录(作废 app_token)"""
|
||||
if authorization:
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if user:
|
||||
user.app_token = ""
|
||||
db.commit()
|
||||
return ok({"success": True})
|
||||
35
digital-avatar-app/backend/routers/organizations.py
Normal file
35
digital-avatar-app/backend/routers/organizations.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Organization
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["组织"])
|
||||
|
||||
|
||||
@router.get("/organizations")
|
||||
def list_orgs(page: int = 1, limit: int = 20, db: Session = Depends(get_db)):
|
||||
q = db.query(Organization)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(Organization.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return ok({"data": [o.to_dict() for o in items], "total": total})
|
||||
|
||||
|
||||
@router.post("/organizations")
|
||||
def create_org(payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
o = Organization(
|
||||
name=payload.get("name", "未命名组织"),
|
||||
description=payload.get("desc", "") or payload.get("description", ""),
|
||||
emoji=payload.get("emoji", "🏢"),
|
||||
org_type=payload.get("type", "") or payload.get("orgType", "team"),
|
||||
)
|
||||
db.add(o)
|
||||
db.commit()
|
||||
db.refresh(o)
|
||||
return ok(o.to_dict())
|
||||
37
digital-avatar-app/backend/routers/tokens.py
Normal file
37
digital-avatar-app/backend/routers/tokens.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import TokenAccount, TokenPlan
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["Token"])
|
||||
|
||||
|
||||
@router.get("/token/balance")
|
||||
def balance(db: Session = Depends(get_db)):
|
||||
acc = db.query(TokenAccount).first()
|
||||
return ok({"balance": acc.balance if acc else 0})
|
||||
|
||||
|
||||
@router.get("/token/plans")
|
||||
def plans(db: Session = Depends(get_db)):
|
||||
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
||||
return ok([p.to_dict() for p in items])
|
||||
|
||||
|
||||
@router.post("/token/charge")
|
||||
def charge(payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
plan_id = payload.get("planId")
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
return fail("套餐不存在", 404)
|
||||
acc = db.query(TokenAccount).first()
|
||||
if not acc:
|
||||
acc = TokenAccount(balance=0)
|
||||
db.add(acc)
|
||||
db.commit()
|
||||
db.refresh(acc)
|
||||
acc.balance += plan.amount
|
||||
db.commit()
|
||||
return ok({"balance": acc.balance, "charged": plan.amount})
|
||||
31
digital-avatar-app/docker-compose.yml
Normal file
31
digital-avatar-app/docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
|
||||
services:
|
||||
avatar-backend:
|
||||
build: ./backend
|
||||
image: avatar-test-backend:latest
|
||||
container_name: avatar-test-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
avatar-frontend:
|
||||
build: .
|
||||
image: avatar-test-frontend:latest
|
||||
container_name: avatar-test-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8099:80" # 浏览器访问 http://<host>:8099
|
||||
depends_on:
|
||||
- avatar-backend
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
networks:
|
||||
avatar-net:
|
||||
driver: bridge
|
||||
22
digital-avatar-app/index.html
Normal file
22
digital-avatar-app/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>会会数字分身</title>
|
||||
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 -->
|
||||
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
||||
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
|
||||
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
|
||||
<script type="text/javascript">
|
||||
window.__APP_CONFIG__ = { apiBase: '' }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
digital-avatar-app/knowledge-samples/牙齿防护知识手册.md
Normal file
50
digital-avatar-app/knowledge-samples/牙齿防护知识手册.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 牙齿防护知识手册
|
||||
|
||||
> 本手册用于「会会」数字分身知识库,覆盖日常护牙、牙线使用、饮食护牙、定期检查、常见口腔问题及儿童口腔保健,供分身专业应答引用。
|
||||
|
||||
## 一、日常刷牙
|
||||
|
||||
- **频率与时机**:每天早晚各刷一次,睡前刷牙尤为重要;饭后建议漱口,进食酸性食物后等待约 30 分钟再刷牙,避免即刻磨损被酸软化的牙釉质。
|
||||
- **巴氏刷牙法(Bass)**:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤 10 次左右,再向牙冠方向拂刷;依次覆盖牙齿外侧、内侧与咬合面。
|
||||
- **时长**:每次刷牙至少 2 分钟。
|
||||
- **工具**:选用软毛、小头牙刷;刷毛外翻或每 3 个月更换一次。
|
||||
- **牙膏**:推荐使用含氟牙膏,氟可增强牙釉质抗酸与再矿化能力。
|
||||
|
||||
## 二、牙线 / 牙缝清洁
|
||||
|
||||
- 每天至少使用一次牙线,清洁牙刷难以到达的牙齿邻面。
|
||||
- 取约 45cm 牙线,以 C 形包绕牙面,上下刮擦清除菌斑。
|
||||
- 牙缝较大或牙周炎人群,可配合使用牙间刷(间隙刷)。
|
||||
|
||||
## 三、饮食与护牙
|
||||
|
||||
- 控制游离糖摄入,少喝含糖饮料、少吃黏性甜食——糖是致龋的主要元凶。
|
||||
- 酸性饮料(碳酸饮料、果汁)会侵蚀牙釉质,建议用吸管饮用并尽快漱口,勿立即刷牙。
|
||||
- 多喝水,唾液具有自我清洁与再矿化作用。
|
||||
- 适量补充钙、磷、维生素 D(奶类、豆制品、深绿色蔬菜)。
|
||||
|
||||
## 四、定期检查与洁治
|
||||
|
||||
- 建议每 6 个月进行一次口腔检查与洁牙(洗牙)。
|
||||
- 洗牙清除牙石与菌斑,预防牙龈炎、牙周炎;**洗牙不会让牙缝变大**(牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成)。
|
||||
- 出现龋齿、牙龈出血、口臭、牙齿敏感应及时就医,越早处理越简单。
|
||||
|
||||
## 五、常见口腔问题
|
||||
|
||||
- **龋齿(蛀牙)**:浅龋可涂氟干预,但已形成龋洞必须充填,无法自愈。
|
||||
- **牙周病**:表现为牙龈红肿、出血、口臭、牙齿松动;基础治疗为洁治 + 龈下刮治,需长期维护。
|
||||
- **牙齿敏感**:遇冷热酸甜刺激痛,可用抗敏感牙膏;若持续加重需就诊排查楔状缺损或牙龈退缩。
|
||||
- **智齿**:阻生或反复发炎的智齿建议拔除。
|
||||
|
||||
## 六、儿童口腔保健
|
||||
|
||||
- 第一颗乳牙萌出(约 6 月龄)即开始清洁,可用纱布或指套牙刷。
|
||||
- **含氟牙膏用量**:3 岁以下用米粒大小,3–6 岁用豌豆大小,均需在家长监督下使用并防止误吞。
|
||||
- **窝沟封闭**:6–7 岁六龄齿萌出后做窝沟封闭,有效预防窝沟龋。
|
||||
- 定期涂氟,每半年一次口腔检查。
|
||||
|
||||
## 七、常见误区澄清
|
||||
|
||||
- 误区「洗牙伤牙」:事实为规范洗牙安全,不损伤牙釉质。
|
||||
- 误区「牙龈出血就不能刷」:事实为出血多因炎症,更应保持清洁并尽早就医。
|
||||
- 误区「乳牙坏了不用管」:事实为乳牙健康直接影响恒牙与颌骨发育。
|
||||
62
digital-avatar-app/knowledge-samples/牙齿防护问答对.json
Normal file
62
digital-avatar-app/knowledge-samples/牙齿防护问答对.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"question": "每天应该刷牙几次?每次刷多久?",
|
||||
"answer": "建议每天早晚各刷一次,每次至少 2 分钟,并使用含氟牙膏;睡前那次尤为重要。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "正确的刷牙方法是什么?",
|
||||
"answer": "推荐巴氏刷牙法:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤约 10 次后再向牙冠方向拂刷,依次清洁牙齿外侧、内侧和咬合面。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "电动牙刷比手动牙刷更好吗?",
|
||||
"answer": "两者清洁效果相当,关键在于正确刷牙方法;电动牙刷更易保证刷牙时长,对刷牙不到位的人更友好。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "刷牙总出血是怎么回事?",
|
||||
"answer": "多为牙龈炎或牙周炎所致,建议尽快洗牙并就医检查,同时坚持正确刷牙和使用牙线,切勿因出血而停止刷牙。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "牙线必须每天用吗?怎么用?",
|
||||
"answer": "建议每天至少使用一次牙线清洁牙齿邻面。取约 45cm 牙线,以 C 形包绕牙面上下刮擦;牙缝较大者可配合牙间刷。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "蛀牙(龋齿)能自己好吗?",
|
||||
"answer": "浅龋可通过涂氟减缓进展,但已形成龋洞必须补牙,无法自愈;越早处理越简单、损伤越小。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "洗牙会让牙缝变大、伤牙齿吗?",
|
||||
"answer": "规范的洗牙安全且不损伤牙釉质。洗牙清除的是牙石,牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "牙齿遇冷热酸甜就酸痛,怎么办?",
|
||||
"answer": "属牙齿敏感,可先使用抗敏感牙膏;若持续加重,需就诊排查楔状缺损或牙龈退缩等成因,再对症处理。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "孩子几岁开始用含氟牙膏?用多少?",
|
||||
"answer": "第一颗乳牙萌出后即可在家长帮助下刷牙;3 岁以下用米粒大小含氟牙膏,3–6 岁用豌豆大小,均需监督防误吞。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "儿童六龄齿需要做窝沟封闭吗?",
|
||||
"answer": "建议 6–7 岁六龄齿萌出后做窝沟封闭,并定期涂氟、每半年口腔检查,可有效预防窝沟龋,保护恒牙。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "喝碳酸饮料会伤牙吗?怎么喝更好?",
|
||||
"answer": "碳酸饮料酸性强,会侵蚀牙釉质。建议用吸管减少接触、喝完尽快清水漱口,不要立即刷牙,并控制频次。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "多久做一次口腔检查和洗牙?",
|
||||
"answer": "建议每 6 个月进行一次口腔检查与洁牙,以便早期发现龋齿、牙周问题并及时干预。",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
38
digital-avatar-app/nginx.conf
Normal file
38
digital-avatar-app/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
# 完整主配置:覆盖 nginx:alpine 默认 /etc/nginx/nginx.conf
|
||||
# 新版 nginx 在受限容器内写 /run/nginx.pid 会报 Operation not permitted 并致命退出,
|
||||
# 这里把 pid 显式改到可写的 /tmp(main 上下文唯一一处),避免前端容器反复重启。
|
||||
pid /dev/null;
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA 兜底(hash 路由下深链接也可正常加载)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端 API:保留 /api 前缀转发到 avatar-backend:8000
|
||||
location /api/ {
|
||||
proxy_pass http://avatar-backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
1796
digital-avatar-app/package-lock.json
generated
Normal file
1796
digital-avatar-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
digital-avatar-app/package.json
Normal file
22
digital-avatar-app/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "digital-avatar-app",
|
||||
"version": "1.0.0",
|
||||
"description": "会会数字分身 Web App",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0"
|
||||
}
|
||||
}
|
||||
119
digital-avatar-app/src/App.vue
Normal file
119
digital-avatar-app/src/App.vue
Normal 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>
|
||||
55
digital-avatar-app/src/main.ts
Normal file
55
digital-avatar-app/src/main.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './store'
|
||||
import { getLaunchParams, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { setAuthToken } from '@/api'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
|
||||
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
||||
const params = getLaunchParams()
|
||||
const avatarStore = useAvatarStore(pinia)
|
||||
const userStore = useUserStore(pinia)
|
||||
|
||||
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户)
|
||||
userStore.loadFromStorage()
|
||||
if (userStore.isLogin && userStore.user) {
|
||||
setAuthToken(userStore.token)
|
||||
avatarStore.setNativeProfile({
|
||||
userId: (userStore.user as any).huihuiUserId || '',
|
||||
nickname: userStore.user.nickname || '',
|
||||
avatarUrl: userStore.user.avatarUrl || ''
|
||||
})
|
||||
}
|
||||
|
||||
if (params.token) {
|
||||
setAuthToken(params.token)
|
||||
}
|
||||
if (params.userId || params.nickname || params.avatar) {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: params.userId || '',
|
||||
nickname: params.nickname || '',
|
||||
avatarUrl: params.avatar || ''
|
||||
})
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
|
||||
onNativeMessage((msg) => {
|
||||
if (!msg || !msg.type) return
|
||||
if (msg.type === 'tokenRefresh' && msg.token) {
|
||||
setAuthToken(msg.token)
|
||||
}
|
||||
if (msg.type === 'userUpdate' && msg.user) {
|
||||
avatarStore.setNativeProfile(msg.user)
|
||||
}
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 通知原生壳:H5 已就绪
|
||||
UniEvents.ready()
|
||||
95
digital-avatar-app/src/store/avatar.ts
Normal file
95
digital-avatar-app/src/store/avatar.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getUserProfile } from '@/api'
|
||||
import { unwrapListData } from '@/utils/avatar-page-data'
|
||||
|
||||
export const useAvatarStore = defineStore('avatar', () => {
|
||||
// 已创建的分身列表(来自后端)
|
||||
const avatars = ref<any[]>([])
|
||||
// 全局 Token 余额(来自后端)
|
||||
const tokenBalance = ref<number>(0)
|
||||
// 当前选中分身 id
|
||||
const currentAvatarId = ref<string | null>(null)
|
||||
// 会会用户资料(头像/昵称,来自会会接口)
|
||||
const userProfile = ref<any>(null)
|
||||
|
||||
// 拉取分身列表
|
||||
const loadAvatars = async () => {
|
||||
try {
|
||||
const res = await getAvatarList()
|
||||
avatars.value = unwrapListData(res)
|
||||
if (avatars.value.length) currentAvatarId.value = avatars.value[0].id
|
||||
} catch (e) {
|
||||
console.error('加载分身失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取 Token 余额
|
||||
const loadTokenBalance = async () => {
|
||||
try {
|
||||
const res = await getTokenBalance()
|
||||
tokenBalance.value = (res as any)?.balance ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取会会用户资料(头像/昵称)
|
||||
const loadUserProfile = async () => {
|
||||
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
|
||||
if (userProfile.value && (userProfile.value as any).__native) return
|
||||
try {
|
||||
const res = await getUserProfile()
|
||||
userProfile.value = res as any
|
||||
} catch (e) {
|
||||
console.error('加载用户资料失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 由 uniapp 壳注入的会会资料(混合架构),标记为原生来源
|
||||
const setNativeProfile = (profile: { userId?: string; nickname?: string; avatarUrl?: string }) => {
|
||||
userProfile.value = {
|
||||
userId: profile.userId || '',
|
||||
nickname: profile.nickname || '',
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
__native: true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建分身(写入后端)
|
||||
const addAvatar = async (avatar: any) => {
|
||||
const res: any = await apiCreate(avatar)
|
||||
const created = res?.data ? res.data : res
|
||||
avatars.value.unshift(created)
|
||||
currentAvatarId.value = created.id
|
||||
return created
|
||||
}
|
||||
|
||||
// 删除分身(写入后端,级联清理关联数据)
|
||||
const removeAvatar = async (id: string) => {
|
||||
await apiDelete(id)
|
||||
avatars.value = avatars.value.filter((a) => a.id !== id)
|
||||
if (currentAvatarId.value === id) {
|
||||
currentAvatarId.value = avatars.value[0]?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
// 同步余额(充值后)
|
||||
const setTokenBalance = (balance: number) => {
|
||||
tokenBalance.value = balance
|
||||
}
|
||||
|
||||
return {
|
||||
avatars,
|
||||
tokenBalance,
|
||||
currentAvatarId,
|
||||
userProfile,
|
||||
loadAvatars,
|
||||
loadTokenBalance,
|
||||
loadUserProfile,
|
||||
setNativeProfile,
|
||||
addAvatar,
|
||||
removeAvatar,
|
||||
setTokenBalance
|
||||
}
|
||||
})
|
||||
5
digital-avatar-app/src/store/index.ts
Normal file
5
digital-avatar-app/src/store/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
82
digital-avatar-app/src/store/user.ts
Normal file
82
digital-avatar-app/src/store/user.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { setAuthToken, sendSmsCode, loginBySms, loginByPassword, logoutUser, type UserProfile } from '@/api'
|
||||
|
||||
const TOKEN_KEY = 'hh_app_token'
|
||||
const USER_KEY = 'hh_app_user'
|
||||
|
||||
// 会会短信登录的本地会话(打通 会会 userId ↔ 本系统用户)
|
||||
export const useUserStore = defineStore('smsuser', () => {
|
||||
const token = ref<string>('')
|
||||
const user = ref<(UserProfile & { huihuiUserId?: string; phone?: string }) | null>(null)
|
||||
const isLogin = ref(false)
|
||||
|
||||
// 启动时从本地恢复会话
|
||||
const loadFromStorage = () => {
|
||||
const t = localStorage.getItem(TOKEN_KEY)
|
||||
const u = localStorage.getItem(USER_KEY)
|
||||
if (t && u) {
|
||||
try {
|
||||
token.value = t
|
||||
user.value = JSON.parse(u)
|
||||
isLogin.value = true
|
||||
setAuthToken(t)
|
||||
} catch {
|
||||
clearLocal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persist = () => {
|
||||
localStorage.setItem(TOKEN_KEY, token.value)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user.value))
|
||||
}
|
||||
|
||||
const clearLocal = () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
// 发送验证码(返回结果,演示模式含 devCode)
|
||||
const sendCode = async (phone: string) => {
|
||||
return await sendSmsCode(phone)
|
||||
}
|
||||
|
||||
// 短信登录
|
||||
const login = async (phone: string, code: string) => {
|
||||
const res: any = await loginBySms(phone, code)
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
}
|
||||
|
||||
// 账号密码登录
|
||||
const loginByPwd = async (account: string, password: string) => {
|
||||
const res: any = await loginByPassword(account, password)
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
}
|
||||
|
||||
// 退出
|
||||
const logout = async () => {
|
||||
try {
|
||||
await logoutUser()
|
||||
} catch {
|
||||
/* 忽略网络错误,本地清除即可 */
|
||||
}
|
||||
token.value = ''
|
||||
user.value = null
|
||||
isLogin.value = false
|
||||
setAuthToken(null)
|
||||
clearLocal()
|
||||
}
|
||||
|
||||
return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout }
|
||||
})
|
||||
58
digital-avatar-app/src/styles/global.css
Normal file
58
digital-avatar-app/src/styles/global.css
Normal file
@@ -0,0 +1,58 @@
|
||||
/* 全局样式 - 会会数字分身 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #F97316;
|
||||
--primary-light: #FFF0E6;
|
||||
--text-primary: #18191C;
|
||||
--text-secondary: #9398AE;
|
||||
--bg-primary: #F8F9FA;
|
||||
--bg-white: #FFFFFF;
|
||||
--border-color: #EDEEF1;
|
||||
--success-color: #22C55E;
|
||||
--warning-color: #F59E0B;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #D1D5DB;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 通用工具类 */
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
65
digital-avatar-app/src/utils/uniapp-bridge.ts
Normal file
65
digital-avatar-app/src/utils/uniapp-bridge.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
|
||||
// 协议详见 uniapp-avatar/README.md
|
||||
//
|
||||
// 引入方式:在 index.html 中加载 uniapp web-view bridge:
|
||||
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
||||
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
|
||||
|
||||
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
||||
|
||||
export interface UniLaunchParams {
|
||||
token?: string
|
||||
userId?: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
ts?: string
|
||||
}
|
||||
|
||||
// 是否运行在 uniapp web-view 环境中
|
||||
export function isInUniWebView(): boolean {
|
||||
return !!(window as any).uni?.webView
|
||||
}
|
||||
|
||||
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||
export function getLaunchParams(): UniLaunchParams {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const params: UniLaunchParams = {}
|
||||
const token = sp.get('token')
|
||||
const userId = sp.get('userId')
|
||||
const nickname = sp.get('nickname')
|
||||
const avatar = sp.get('avatar')
|
||||
const ts = sp.get('ts')
|
||||
if (token) params.token = token
|
||||
if (userId) params.userId = userId
|
||||
if (nickname) params.nickname = decodeURIComponent(nickname)
|
||||
if (avatar) params.avatar = decodeURIComponent(avatar)
|
||||
if (ts) params.ts = ts
|
||||
return params
|
||||
}
|
||||
|
||||
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
|
||||
export function postToNative(message: Record<string, any>): boolean {
|
||||
if (!isInUniWebView()) return false
|
||||
;(window as any).uni.webView.postMessage({ data: message })
|
||||
return true
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
|
||||
export function onNativeMessage(handler: (message: any) => void): void {
|
||||
;(window as any)[BRIDGE_HANDLER] = (message: any) => {
|
||||
try {
|
||||
handler(message)
|
||||
} catch (e) {
|
||||
console.error('[uniBridge] handler error', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷事件
|
||||
export const UniEvents = {
|
||||
ready: () => postToNative({ type: 'ready' }),
|
||||
needLogin: () => postToNative({ type: 'needLogin' }),
|
||||
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
||||
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
||||
back: () => postToNative({ type: 'back' })
|
||||
}
|
||||
324
digital-avatar-app/src/views/AuthorizationManage.vue
Normal file
324
digital-avatar-app/src/views/AuthorizationManage.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="auth-manage-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">授权管理</h1>
|
||||
<button class="add-btn" @click="addAuthorization">+</button>
|
||||
</header>
|
||||
|
||||
<!-- 授权列表 -->
|
||||
<section class="auth-list" v-if="authList.length > 0">
|
||||
<div class="auth-card" v-for="auth in authList" :key="auth.id">
|
||||
<div class="auth-icon" :class="auth.targetType">
|
||||
{{ getAuthIcon(auth.targetType) }}
|
||||
</div>
|
||||
<div class="auth-info">
|
||||
<h3 class="auth-name">{{ auth.targetName }}</h3>
|
||||
<p class="auth-type">{{ getAuthTypeText(auth.targetType) }}</p>
|
||||
<div class="auth-permissions">
|
||||
<span class="permission-tag" v-for="perm in auth.permissions" :key="perm">
|
||||
{{ getPermissionText(perm) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-actions">
|
||||
<span class="auth-status" :class="auth.status">
|
||||
{{ auth.status === 'active' ? '已授权' : '已撤销' }}
|
||||
</span>
|
||||
<button class="auth-toggle-btn" @click="toggleAuth(auth)">
|
||||
{{ auth.status === 'active' ? '撤销' : '授权' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<section class="empty-state" v-else>
|
||||
<div class="empty-icon">🔐</div>
|
||||
<h3 class="empty-title">暂无授权</h3>
|
||||
<p class="empty-desc">授权其他用户或应用访问你的数字分身</p>
|
||||
<button class="empty-btn" @click="addAuthorization">添加授权</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { getAuthorizationList, updateAuthorization } from '@/api'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
|
||||
// 授权列表
|
||||
const authList = ref<Array<{
|
||||
id: string
|
||||
targetType: 'user' | 'organization' | 'application'
|
||||
targetName: string
|
||||
permissions: string[]
|
||||
status: 'active' | 'inactive'
|
||||
}>>([])
|
||||
|
||||
// 从后端加载授权列表
|
||||
const loadAuth = async () => {
|
||||
try {
|
||||
if (!avatarStore.avatars.length) {
|
||||
await avatarStore.loadAvatars()
|
||||
}
|
||||
const avatarId = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars)
|
||||
if (!avatarId) {
|
||||
authList.value = []
|
||||
return
|
||||
}
|
||||
const res: any = await getAuthorizationList(avatarId)
|
||||
authList.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
console.error('加载授权失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取授权图标
|
||||
const getAuthIcon = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'user': '👤',
|
||||
'organization': '🏢',
|
||||
'application': '📱'
|
||||
}
|
||||
return map[type] || '🔑'
|
||||
}
|
||||
|
||||
// 获取授权类型文本
|
||||
const getAuthTypeText = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'user': '用户',
|
||||
'organization': '组织',
|
||||
'application': '应用'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 获取权限文本
|
||||
const getPermissionText = (perm: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'read': '读取',
|
||||
'write': '写入',
|
||||
'reply': '回复',
|
||||
'edit': '编辑'
|
||||
}
|
||||
return map[perm] || perm
|
||||
}
|
||||
|
||||
// 切换授权状态(写入后端)
|
||||
const toggleAuth = async (auth: any) => {
|
||||
const newStatus = auth.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
const avatarId = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars)
|
||||
if (!avatarId) return
|
||||
const res: any = await updateAuthorization(avatarId, {
|
||||
id: auth.id,
|
||||
status: newStatus
|
||||
})
|
||||
authList.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
alert('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 添加授权
|
||||
const addAuthorization = () => {
|
||||
alert('添加授权功能开发中...')
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-manage-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 授权列表 */
|
||||
.auth-list {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
font-size: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: #FFF0E6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.auth-type {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.auth-permissions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.permission-tag {
|
||||
padding: 4px 8px;
|
||||
background: #F3F4F6;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-status.active {
|
||||
color: #22C55E;
|
||||
}
|
||||
|
||||
.auth-status.inactive {
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.auth-toggle-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
padding: 12px 32px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
185
digital-avatar-app/src/views/AvatarCard.vue
Normal file
185
digital-avatar-app/src/views/AvatarCard.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="avatar-card-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身名片</h1>
|
||||
</div>
|
||||
<button class="share-btn" @click="shareCard">分享</button>
|
||||
</header>
|
||||
|
||||
<!-- 名片预览 -->
|
||||
<section class="card-preview">
|
||||
<div class="digital-card">
|
||||
<div class="card-top">
|
||||
<div class="card-avatar">{{ avatar.emoji }}</div>
|
||||
<span class="card-status" :class="avatar.status">● {{ statusText(avatar.status) }}</span>
|
||||
</div>
|
||||
<h2 class="card-name">{{ avatar.displayName }}</h2>
|
||||
<p class="card-desc">{{ avatar.description }}</p>
|
||||
<div class="card-tags">
|
||||
<span class="card-tag" v-for="t in avatar.tags" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
<div class="card-qr">
|
||||
<div class="qr-placeholder" aria-hidden="true">▦</div>
|
||||
<span class="qr-tip">扫码添加我的分身</span>
|
||||
</div>
|
||||
<div class="card-id">会会号:{{ avatar.huihuiId }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 名片信息 -->
|
||||
<section class="info-section">
|
||||
<h3 class="section-title">名片信息</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row"><span class="info-label">分身名称</span><span class="info-value">{{ avatar.displayName }}</span></div>
|
||||
<div class="info-row"><span class="info-label">会会号</span><span class="info-value">{{ avatar.huihuiId }}</span></div>
|
||||
<div class="info-row"><span class="info-label">状态</span><span class="info-value">{{ statusText(avatar.status) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">创建时间</span><span class="info-value">{{ formatDate(avatar.createdAt) }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作 -->
|
||||
<section class="action-section">
|
||||
<button class="primary-btn" @click="copyLink">复制分享链接</button>
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { getAvatarDetail } from '@/api'
|
||||
import { pickAvatarId } from '@/utils/avatar-page-data'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const avatarStore = useAvatarStore()
|
||||
const toast = ref('')
|
||||
const loading = ref(true)
|
||||
const selectedAvatarId = ref('')
|
||||
|
||||
const avatar = ref({
|
||||
emoji: '🤖',
|
||||
displayName: '会会助手',
|
||||
description: '我是您的AI数字分身,可以帮您管理日程、回复消息、处理任务。',
|
||||
huihuiId: 'huihui_8848',
|
||||
status: 'active',
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
tags: ['智能对话', '日程管理', '社交助手']
|
||||
})
|
||||
|
||||
const applyAvatar = (a: any) => {
|
||||
if (!a) return
|
||||
avatar.value = {
|
||||
emoji: a.emoji || '🤖',
|
||||
displayName: a.displayName || a.name || '我的分身',
|
||||
description: a.description || '暂无描述',
|
||||
huihuiId: a.huihuiId || 'huihui_' + (a.id || '0000'),
|
||||
status: a.status || 'active',
|
||||
createdAt: a.createdAt || new Date().toISOString(),
|
||||
tags: a.tags || ['智能对话', '日程管理']
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||||
const requestedId = String(route.query.id || '')
|
||||
const id = pickAvatarId(requestedId, avatarStore.avatars)
|
||||
selectedAvatarId.value = id || ''
|
||||
const localAvatar = avatarStore.avatars.find((a) => String(a.id) === id)
|
||||
if (localAvatar) {
|
||||
applyAvatar(localAvatar)
|
||||
} else if (requestedId) {
|
||||
applyAvatar(await getAvatarDetail(requestedId))
|
||||
selectedAvatarId.value = requestedId
|
||||
} else {
|
||||
applyAvatar(avatarStore.avatars[0])
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = (s: string) => ({ active: '活跃中', inactive: '未激活', training: '训练中' }[s] || '活跃中')
|
||||
const formatDate = (t: string) => new Date(t).toLocaleDateString('zh-CN')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const shareCard = () => {
|
||||
toast.value = '请复制链接分享给对方'
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
const copyLink = async () => {
|
||||
const id = selectedAvatarId.value
|
||||
const redirect = router.resolve({ path: '/avatar/card', query: id ? { id: String(id) } : undefined }).href
|
||||
const loginUrl = router.resolve({ path: '/login/sms', query: { redirect } }).href
|
||||
const hash = loginUrl.includes('#') ? loginUrl.slice(loginUrl.indexOf('#')) : `#${loginUrl}`
|
||||
const link = `${window.location.origin}${window.location.pathname}${hash}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
toast.value = '分享链接已复制'
|
||||
} catch {
|
||||
toast.value = link
|
||||
}
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-card-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.share-btn { background: rgba(255,255,255,0.2); border: none; color: white; padding: 6px 16px; border-radius: 20px; font-size: 14px; cursor: pointer; }
|
||||
|
||||
.card-preview { padding: 16px 20px; }
|
||||
.digital-card {
|
||||
background: linear-gradient(160deg, #FFF7ED 0%, #FFFFFF 60%);
|
||||
border: 1px solid #FFE4CC; border-radius: 20px; padding: 24px;
|
||||
box-shadow: 0 8px 24px rgba(249,115,22,0.12);
|
||||
}
|
||||
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.card-avatar {
|
||||
width: 64px; height: 64px; border-radius: 18px; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 34px;
|
||||
}
|
||||
.card-status { font-size: 12px; color: #22C55E; background: #ECFDF5; padding: 4px 10px; border-radius: 20px; }
|
||||
.card-name { font-size: 22px; font-weight: 700; color: #18191C; margin: 0 0 8px; }
|
||||
.card-desc { font-size: 14px; color: #6B7280; line-height: 1.6; margin: 0 0 16px; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||
.card-tag { padding: 4px 12px; background: #FFF0E6; color: #F97316; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||
.card-qr {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 16px; background: white; border-radius: 14px; border: 1px dashed #FED7AA; margin-bottom: 14px;
|
||||
}
|
||||
.qr-placeholder { font-size: 56px; line-height: 1; color: #F97316; letter-spacing: 4px; }
|
||||
.qr-tip { font-size: 12px; color: #9398AE; }
|
||||
.card-id { text-align: center; font-size: 13px; color: #9398AE; }
|
||||
|
||||
.info-section { padding: 0 20px 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; margin: 0 0 12px; color: #18191C; }
|
||||
.info-list { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); overflow: hidden; }
|
||||
.info-row { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #F3F4F6; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-size: 14px; color: #6B7280; }
|
||||
.info-value { font-size: 14px; color: #18191C; font-weight: 500; }
|
||||
|
||||
.action-section { padding: 0 20px; }
|
||||
.primary-btn {
|
||||
width: 100%; padding: 14px; background: #F97316; color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: opacity 0.2s;
|
||||
}
|
||||
.primary-btn:hover { opacity: 0.9; }
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 12px 0 0; word-break: break-all; }
|
||||
</style>
|
||||
142
digital-avatar-app/src/views/AvatarContacts.vue
Normal file
142
digital-avatar-app/src/views/AvatarContacts.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="contacts-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身人脉</h1>
|
||||
</div>
|
||||
<button class="add-btn" @click="addContact">+</button>
|
||||
</header>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<section class="search-section">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input v-model="keyword" class="search-input" placeholder="搜索人脉" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 人脉列表 -->
|
||||
<section class="contacts-section">
|
||||
<div class="contact-list" v-if="filteredContacts.length > 0">
|
||||
<div class="contact-item" v-for="c in filteredContacts" :key="c.id">
|
||||
<div class="contact-avatar">{{ c.emoji }}</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name-row">
|
||||
<span class="contact-name">{{ c.name }}</span>
|
||||
<span class="relation-tag" :class="c.relation">{{ relationText(c.relation) }}</span>
|
||||
</div>
|
||||
<span class="contact-last">最近互动:{{ c.last }}</span>
|
||||
</div>
|
||||
<button class="chat-btn" @click="openChat(c)">💬</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">🤝</span>
|
||||
<p class="empty-text">还没有人脉,点击右上角添加</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const toast = ref('')
|
||||
|
||||
interface Contact { id: string; emoji: string; name: string; relation: string; last: string }
|
||||
const contacts = ref<Contact[]>([
|
||||
{ id: '1', emoji: '👩', name: '林小满', relation: 'friend', last: '今天 14:20' },
|
||||
{ id: '2', emoji: '🧑', name: '陈工', relation: 'colleague', last: '昨天 09:10' },
|
||||
{ id: '3', emoji: '👨', name: '王总', relation: 'client', last: '3 天前' },
|
||||
{ id: '4', emoji: '🧑', name: 'Anna', relation: 'friend', last: '上周' }
|
||||
])
|
||||
|
||||
const filteredContacts = computed(() =>
|
||||
contacts.value.filter(c => c.name.includes(keyword.value))
|
||||
)
|
||||
|
||||
const relationText = (r: string) => ({
|
||||
friend: '好友', colleague: '同事', client: '客户'
|
||||
}[r] || '好友')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const addContact = () => {
|
||||
const sample = ['👤', '🧑', '👩', '🧑']
|
||||
const names = ['新朋友', '合作伙伴', '张同学', '李经理']
|
||||
const relations = ['friend', 'colleague', 'client']
|
||||
const idx = contacts.value.length
|
||||
contacts.value.unshift({
|
||||
id: String(Date.now()),
|
||||
emoji: sample[idx % sample.length],
|
||||
name: names[idx % names.length],
|
||||
relation: relations[idx % relations.length],
|
||||
last: '刚刚添加'
|
||||
})
|
||||
toast.value = '已添加示例人脉'
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
const openChat = (c: Contact) => {
|
||||
toast.value = `打开与 ${c.name} 的对话(示例)`
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contacts-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.add-btn { background: rgba(255,255,255,0.2); border: none; color: white; font-size: 22px; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; }
|
||||
|
||||
.search-section { padding: 16px 20px; }
|
||||
.search-box { display: flex; align-items: center; gap: 8px; background: white; border-radius: 12px; padding: 10px 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.search-icon { font-size: 16px; }
|
||||
.search-input { flex: 1; border: none; outline: none; font-size: 14px; color: #18191C; background: transparent; }
|
||||
|
||||
.contacts-section { padding: 0 20px; }
|
||||
.contact-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.contact-item {
|
||||
display: flex; align-items: center; gap: 12px; padding: 14px 16px;
|
||||
background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.contact-avatar {
|
||||
width: 44px; height: 44px; border-radius: 50%; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.contact-info { flex: 1; min-width: 0; }
|
||||
.contact-name-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.contact-name { font-size: 15px; font-weight: 600; color: #18191C; }
|
||||
.relation-tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.relation-tag.friend { background: #ECFDF5; color: #22C55E; }
|
||||
.relation-tag.colleague { background: #EFF6FF; color: #3B82F6; }
|
||||
.relation-tag.client { background: #FFF0E6; color: #F97316; }
|
||||
.contact-last { font-size: 12px; color: #9398AE; }
|
||||
.chat-btn {
|
||||
width: 38px; height: 38px; border-radius: 50%; border: none; background: #FFF0E6;
|
||||
font-size: 18px; cursor: pointer; flex-shrink: 0; transition: transform 0.2s;
|
||||
}
|
||||
.chat-btn:hover { transform: scale(1.08); }
|
||||
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 40px 20px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9398AE; margin: 0; }
|
||||
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 16px 20px 0; }
|
||||
</style>
|
||||
492
digital-avatar-app/src/views/AvatarCreate.vue
Normal file
492
digital-avatar-app/src/views/AvatarCreate.vue
Normal file
@@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<div class="create-page">
|
||||
<!-- 步骤 0:引导落地页 -->
|
||||
<div v-if="step === 0" class="landing">
|
||||
<header class="hero">
|
||||
<div class="hero-icon">🤖</div>
|
||||
<h1 class="hero-title">创建你的数字分身</h1>
|
||||
<p class="hero-sub">AI 助手,为你工作、社交、创造价值</p>
|
||||
</header>
|
||||
|
||||
<section class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💬</div>
|
||||
<h3>智能对话</h3>
|
||||
<p>24/7 在线,自动回复消息、处理咨询</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📅</div>
|
||||
<h3>日程管理</h3>
|
||||
<p>智能安排会议、提醒重要事项</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🤝</div>
|
||||
<h3>社交助手</h3>
|
||||
<p>管理人脉、扩展社交圈</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<button class="create-btn" @click="step = 1">
|
||||
<span class="btn-icon">✨</span> 开始创建
|
||||
</button>
|
||||
<p class="hint">只需 4 步,快速拥有你的 AI 分身</p>
|
||||
</section>
|
||||
|
||||
<section class="existing" v-if="avatarStore.avatars.length > 0">
|
||||
<p>已有数字分身?</p>
|
||||
<button class="link-btn" @click="goManage">前往管理 →</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 1-4:创建向导 -->
|
||||
<div v-else class="wizard">
|
||||
<header class="wizard-header">
|
||||
<button class="back" @click="prev">‹</button>
|
||||
<h2 class="w-title">{{ stepTitles[step - 1] }}</h2>
|
||||
<span class="step-count">{{ step }}/4</span>
|
||||
</header>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: (step / 4 * 100) + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
<!-- Step 1:基础信息 -->
|
||||
<div v-if="step === 1" class="form">
|
||||
<div class="field">
|
||||
<label class="field-label">分身头像</label>
|
||||
<div class="avatar-pick">
|
||||
<div class="avatar-pick-preview">
|
||||
<img v-if="form.photoUrl" :src="form.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ form.emoji }}</span>
|
||||
</div>
|
||||
<p class="avatar-pick-hint">已沿用你的会会头像,也可在下一步选择表情形象</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">分身名称 <span class="req">*</span></label>
|
||||
<input v-model="form.name" class="input" placeholder="如:我的数字分身" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">显示名称</label>
|
||||
<input v-model="form.displayName" class="input" placeholder="如:会会助手" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">分身描述</label>
|
||||
<textarea v-model="form.description" class="textarea" rows="3" placeholder="描述它的功能与特点"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2:形象风格 -->
|
||||
<div v-else-if="step === 2" class="form">
|
||||
<p class="field-label">选择形象</p>
|
||||
|
||||
<!-- 默认:沿用当前用户的会会头像 -->
|
||||
<div
|
||||
v-if="userAvatarUrl"
|
||||
class="avatar-photo-option"
|
||||
:class="{ active: usePhoto }"
|
||||
@click="selectMyAvatar"
|
||||
>
|
||||
<div class="apo-thumb">
|
||||
<img :src="userAvatarUrl" alt="" referrerpolicy="no-referrer" />
|
||||
</div>
|
||||
<div class="apo-meta">
|
||||
<span class="apo-title">使用我的头像</span>
|
||||
<span class="apo-sub">沿用你的会会账号头像</span>
|
||||
</div>
|
||||
<span class="apo-check">✓</span>
|
||||
</div>
|
||||
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="e in emojis"
|
||||
:key="e"
|
||||
class="emoji-opt"
|
||||
:class="{ active: !usePhoto && form.emoji === e }"
|
||||
@click="selectEmoji(e)"
|
||||
>{{ e }}</button>
|
||||
</div>
|
||||
<p class="field-label">回复风格</p>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="s in styles"
|
||||
:key="s.value"
|
||||
class="seg-btn"
|
||||
:class="{ active: form.replyStyle === s.value }"
|
||||
@click="form.replyStyle = s.value"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3:灵魂配置 -->
|
||||
<div v-else-if="step === 3" class="form">
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>创造力</span><b>{{ form.creativity }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.creativity" class="range" />
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>严谨度</span><b>{{ form.rigor }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.rigor" class="range" />
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>幽默感</span><b>{{ form.humor }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.humor" class="range" />
|
||||
</div>
|
||||
<p class="field-label">回复长度</p>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="l in lengths"
|
||||
:key="l.value"
|
||||
class="seg-btn"
|
||||
:class="{ active: form.responseLength === l.value }"
|
||||
@click="form.responseLength = l.value"
|
||||
>{{ l.label }}</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">系统提示词(选填)</label>
|
||||
<textarea v-model="form.systemPrompt" class="textarea" rows="3" placeholder="给分身的额外指令,如:语气要专业、回答要简洁"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4:确认创建 -->
|
||||
<div v-else class="confirm">
|
||||
<div class="confirm-avatar">
|
||||
<img v-if="usePhoto && form.photoUrl" :src="form.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ form.emoji }}</span>
|
||||
</div>
|
||||
<h3 class="confirm-name">{{ form.displayName || form.name || '未命名分身' }}</h3>
|
||||
<p class="confirm-desc">{{ form.description || '暂无描述' }}</p>
|
||||
<ul class="confirm-list">
|
||||
<li><span>回复风格</span><b>{{ styleLabel }}</b></li>
|
||||
<li><span>创造力 / 严谨度 / 幽默感</span><b>{{ form.creativity }} / {{ form.rigor }} / {{ form.humor }}</b></li>
|
||||
<li><span>回复长度</span><b>{{ lengthLabel }}</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="wizard-footer">
|
||||
<button v-if="step > 1" class="btn-secondary" @click="prev">上一步</button>
|
||||
<button v-if="step < 4" class="btn-primary" :disabled="!canNext" @click="next">下一步</button>
|
||||
<button v-else class="btn-primary" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? '创建中...' : '创建分身' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { getCurrentUser } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const step = ref(0)
|
||||
const stepTitles = ['基础信息', '形象风格', '灵魂配置', '确认创建']
|
||||
const submitting = ref(false)
|
||||
|
||||
const emojis = ['🤖', '😊', '🦊', '🐱', '🦁', '🐼', '👩💻', '🧑🚀', '💡', '🌟']
|
||||
const styles = [
|
||||
{ value: 'professional', label: '专业' },
|
||||
{ value: 'casual', label: '轻松' },
|
||||
{ value: 'friendly', label: '亲切' }
|
||||
]
|
||||
const lengths = [
|
||||
{ value: 'short', label: '简短' },
|
||||
{ value: 'medium', label: '适中' },
|
||||
{ value: 'long', label: '详细' }
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
displayName: '',
|
||||
photoUrl: '',
|
||||
description: '',
|
||||
emoji: '🤖',
|
||||
replyStyle: 'professional',
|
||||
creativity: 50,
|
||||
rigor: 50,
|
||||
humor: 30,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: ''
|
||||
})
|
||||
|
||||
const styleLabel = computed(() => styles.find(s => s.value === form.replyStyle)?.label || '')
|
||||
const lengthLabel = computed(() => lengths.find(l => l.value === form.responseLength)?.label || '')
|
||||
|
||||
// 当前登录用户的会会头像(用于「形象选择」默认项)
|
||||
// 优先采用真实登录态 userStore.user,避免被空的 userProfile 覆盖
|
||||
const userAvatarUrl = computed(() => {
|
||||
const u = userStore.user
|
||||
const p = avatarStore.userProfile
|
||||
return ((u?.avatarUrl as string) || (p?.avatarUrl as string) || '')
|
||||
})
|
||||
|
||||
// 形象选择:默认沿用当前用户的会会头像;选中表情后切换为 emoji 模式
|
||||
const usePhoto = ref(false)
|
||||
const selectMyAvatar = () => {
|
||||
usePhoto.value = true
|
||||
form.photoUrl = userAvatarUrl.value
|
||||
}
|
||||
const selectEmoji = (e: string) => {
|
||||
form.emoji = e
|
||||
usePhoto.value = false
|
||||
form.photoUrl = ''
|
||||
}
|
||||
|
||||
const canNext = computed(() => {
|
||||
if (step.value === 1) return form.name.trim().length > 0
|
||||
return true
|
||||
})
|
||||
|
||||
const next = () => { if (step.value < 4) step.value++ }
|
||||
const prev = () => { if (step.value === 1) step.value = 0; else step.value-- }
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await avatarStore.addAvatar({
|
||||
name: form.name,
|
||||
displayName: form.displayName || form.name,
|
||||
photoUrl: usePhoto.value ? form.photoUrl : '',
|
||||
description: form.description,
|
||||
emoji: form.emoji,
|
||||
config: {
|
||||
replyStyle: form.replyStyle,
|
||||
creativity: form.creativity,
|
||||
rigor: form.rigor,
|
||||
humor: form.humor,
|
||||
responseLength: form.responseLength,
|
||||
systemPrompt: form.systemPrompt
|
||||
}
|
||||
})
|
||||
router.push('/avatar/manage')
|
||||
} catch (e) {
|
||||
alert('创建失败,请稍后重试')
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 预填会会登录用户的名称与头像(沿用当前账号资料)
|
||||
// 优先采用真实登录态 userStore.user(持久化会话),避免被空资料 / mock 覆盖
|
||||
onMounted(async () => {
|
||||
userStore.loadFromStorage()
|
||||
const real = userStore.user
|
||||
const native = avatarStore.userProfile
|
||||
let nick = real?.nickname || native?.nickname || ''
|
||||
let avatar = real?.avatarUrl || native?.avatarUrl || ''
|
||||
// 兜底:本地资料为空时,向服务端 /huihui/me 拉取真实登录态(Bearer app_token)
|
||||
if ((!nick || !avatar) && userStore.isLogin) {
|
||||
try {
|
||||
const me2: any = await getCurrentUser()
|
||||
nick = nick || me2?.nickname || ''
|
||||
avatar = avatar || me2?.avatarUrl || ''
|
||||
} catch { /* 忽略,保持本地值 */ }
|
||||
}
|
||||
if (nick) {
|
||||
if (!form.displayName) form.displayName = nick
|
||||
if (!form.name) form.name = nick
|
||||
}
|
||||
if (avatar && !form.photoUrl) form.photoUrl = avatar
|
||||
// 「形象选择」默认使用当前用户的会会头像
|
||||
if (userAvatarUrl.value) usePhoto.value = true
|
||||
})
|
||||
|
||||
const goManage = () => router.push('/avatar/manage')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
}
|
||||
|
||||
/* ===== 落地页 ===== */
|
||||
.landing { padding-bottom: 40px; }
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 50%, #FDBA74 100%);
|
||||
padding: 60px 20px 80px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100% { transform: scale(1);} 50% { transform: scale(1.1);} }
|
||||
.hero-title { font-size: 28px; font-weight: 700; margin: 0 0 12px; letter-spacing: 1px; }
|
||||
.hero-sub { font-size: 16px; opacity: 0.9; margin: 0; }
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
margin-top: -40px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.feature-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.feature-icon { font-size: 36px; margin-bottom: 12px; }
|
||||
.feature-card h3 { font-size: 15px; font-weight: 600; color: #18191C; margin: 0 0 8px; }
|
||||
.feature-card p { font-size: 12px; color: #9398AE; margin: 0; line-height: 1.4; }
|
||||
|
||||
.cta { padding: 30px 20px; text-align: center; }
|
||||
.create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 48px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(249,115,22,0.4);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.create-btn:active { transform: scale(0.97); }
|
||||
.hint { font-size: 13px; color: #9398AE; margin-top: 16px; }
|
||||
|
||||
.existing { text-align: center; padding: 10px; }
|
||||
.existing p { font-size: 14px; color: #9398AE; margin: 0 0 12px; }
|
||||
.link-btn {
|
||||
background: none; border: 2px solid #F97316; color: #F97316;
|
||||
padding: 10px 24px; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.link-btn:hover { background: #F97316; color: white; }
|
||||
|
||||
/* ===== 向导 ===== */
|
||||
.wizard { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.wizard-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: white; border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
.back { background: none; border: none; font-size: 26px; cursor: pointer; color: #18191C; padding: 0 8px; }
|
||||
.w-title { font-size: 17px; font-weight: 600; margin: 0; color: #18191C; }
|
||||
.step-count { font-size: 13px; color: #9398AE; }
|
||||
|
||||
.progress { height: 4px; background: #EDEEF1; }
|
||||
.progress-bar { height: 100%; background: linear-gradient(90deg, #F97316, #FB923C); transition: width 0.35s ease; }
|
||||
|
||||
.wizard-body { flex: 1; padding: 24px 20px; }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field-label { font-size: 14px; font-weight: 500; color: #18191C; }
|
||||
.req { color: #EF4444; }
|
||||
|
||||
.avatar-pick { display: flex; flex-direction: column; gap: 10px; }
|
||||
.avatar-pick-preview {
|
||||
width: 72px; height: 72px; border-radius: 50%; overflow: hidden;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 36px; box-shadow: 0 4px 12px rgba(249,115,22,0.3);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
.avatar-pick-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar-pick-hint { font-size: 12px; color: #9398AE; margin: 0; }
|
||||
.input, .textarea {
|
||||
width: 100%; padding: 12px 16px; border: 1px solid #EDEEF1; border-radius: 10px;
|
||||
font-size: 15px; color: #18191C; background: white; font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input:focus, .textarea:focus { outline: none; border-color: #F97316; }
|
||||
.textarea { resize: vertical; }
|
||||
|
||||
.emoji-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
|
||||
.emoji-opt {
|
||||
aspect-ratio: 1; font-size: 28px; border: 2px solid #EDEEF1; border-radius: 12px;
|
||||
background: white; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.emoji-opt.active { border-color: #F97316; background: #FFF0E6; }
|
||||
|
||||
/* 形象选择:默认沿用会会头像 */
|
||||
.avatar-photo-option {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 16px; border: 2px solid #EDEEF1; border-radius: 14px;
|
||||
background: white; cursor: pointer; transition: all 0.2s; margin-bottom: 18px;
|
||||
}
|
||||
.avatar-photo-option.active { border-color: #F97316; background: #FFF0E6; }
|
||||
.apo-thumb {
|
||||
width: 52px; height: 52px; border-radius: 50%; overflow: hidden; flex: 0 0 auto;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
border: 2px solid #fff; box-shadow: 0 2px 8px rgba(249,115,22,0.3);
|
||||
}
|
||||
.apo-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.apo-meta { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.apo-title { font-size: 15px; font-weight: 600; color: #18191C; }
|
||||
.apo-sub { font-size: 12px; color: #9398AE; }
|
||||
.apo-check { font-size: 18px; color: #F97316; opacity: 0; transition: opacity 0.2s; flex: 0 0 auto; }
|
||||
.avatar-photo-option.active .apo-check { opacity: 1; }
|
||||
|
||||
.seg { display: flex; gap: 12px; }
|
||||
.seg-btn {
|
||||
flex: 1; padding: 12px; border: 2px solid #EDEEF1; border-radius: 10px; background: white;
|
||||
font-size: 14px; color: #9398AE; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.seg-btn.active { border-color: #F97316; color: #F97316; background: #FFF0E6; }
|
||||
|
||||
.slider-item { display: flex; flex-direction: column; gap: 10px; }
|
||||
.slider-head { display: flex; justify-content: space-between; font-size: 14px; color: #18191C; }
|
||||
.slider-head b { color: #F97316; }
|
||||
.range { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 3px; background: #EDEEF1; outline: none; }
|
||||
.range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #F97316; cursor: pointer; box-shadow: 0 2px 6px rgba(249,115,22,0.4);
|
||||
}
|
||||
|
||||
.confirm { display: flex; flex-direction: column; align-items: center; gap: 12px; padding-top: 10px; }
|
||||
.confirm-avatar {
|
||||
width: 88px; height: 88px; border-radius: 50%; overflow: hidden;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 44px;
|
||||
box-shadow: 0 4px 12px rgba(249,115,22,0.3);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
.confirm-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.confirm-name { font-size: 20px; font-weight: 700; color: #18191C; margin: 0; }
|
||||
.confirm-desc { font-size: 14px; color: #9398AE; margin: 0; text-align: center; }
|
||||
.confirm-list { width: 100%; list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.confirm-list li {
|
||||
display: flex; justify-content: space-between; padding: 14px 16px; background: white;
|
||||
border-radius: 10px; font-size: 13px; color: #9398AE; box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.confirm-list li b { color: #18191C; font-weight: 600; }
|
||||
|
||||
.wizard-footer {
|
||||
display: flex; gap: 12px; padding: 16px 20px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: white; border-top: 1px solid #EDEEF1;
|
||||
}
|
||||
.btn-secondary {
|
||||
flex: 0 0 auto; padding: 14px 24px; background: white; color: #18191C;
|
||||
border: 2px solid #EDEEF1; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
flex: 1; padding: 14px; background: linear-gradient(135deg, #F97316, #FB923C); color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(249,115,22,0.3); transition: opacity 0.2s;
|
||||
}
|
||||
.btn-primary:disabled { background: #D1D5DB; box-shadow: none; cursor: not-allowed; }
|
||||
</style>
|
||||
157
digital-avatar-app/src/views/CreateOrg.vue
Normal file
157
digital-avatar-app/src/views/CreateOrg.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="create-org-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">创建组织</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 表单 -->
|
||||
<section class="form-section">
|
||||
<div class="form-card">
|
||||
<!-- Logo 选择 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织标识</label>
|
||||
<div class="emoji-picker">
|
||||
<button
|
||||
v-for="e in emojiOptions"
|
||||
:key="e"
|
||||
class="emoji-option"
|
||||
:class="{ active: form.emoji === e }"
|
||||
@click="form.emoji = e"
|
||||
>{{ e }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织名称</label>
|
||||
<input v-model="form.name" class="text-input" placeholder="例如:会会增长团队" maxlength="20" />
|
||||
</div>
|
||||
|
||||
<!-- 简介 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织简介</label>
|
||||
<textarea v-model="form.desc" class="text-area" rows="3" placeholder="一句话介绍这个组织的用途" maxlength="100"></textarea>
|
||||
<span class="char-count">{{ form.desc.length }}/100</span>
|
||||
</div>
|
||||
|
||||
<!-- 类型 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织类型</label>
|
||||
<div class="type-picker">
|
||||
<button
|
||||
v-for="t in typeOptions"
|
||||
:key="t.value"
|
||||
class="type-option"
|
||||
:class="{ active: form.type === t.value }"
|
||||
@click="form.type = t.value"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 提交 -->
|
||||
<section class="submit-section">
|
||||
<button class="submit-btn" :disabled="!canSubmit || submitting" @click="submit">
|
||||
{{ submitting ? '创建中...' : '创建组织' }}
|
||||
</button>
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createOrganization } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const emojiOptions = ['🏢', '🚀', '💡', '🌟', '🔥', '🐳']
|
||||
const typeOptions = [
|
||||
{ value: 'team', label: '团队' },
|
||||
{ value: 'company', label: '企业' },
|
||||
{ value: 'community', label: '社群' }
|
||||
]
|
||||
|
||||
const form = ref({ emoji: '🏢', name: '', desc: '', type: 'team' })
|
||||
|
||||
const canSubmit = computed(() => form.value.name.trim().length > 0)
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit.value || submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await createOrganization({
|
||||
name: form.value.name,
|
||||
desc: form.value.desc,
|
||||
emoji: form.value.emoji,
|
||||
type: form.value.type
|
||||
})
|
||||
toast.value = `组织「${form.value.name}」创建成功`
|
||||
setTimeout(() => router.push('/avatar/manage'), 1200)
|
||||
} catch (e) {
|
||||
toast.value = '创建失败,请重试'
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-org-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
|
||||
.form-section { padding: 16px 20px; }
|
||||
.form-card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.field { margin-bottom: 20px; position: relative; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field-label { display: block; font-size: 14px; font-weight: 600; color: #18191C; margin-bottom: 10px; }
|
||||
|
||||
.emoji-picker { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.emoji-option {
|
||||
width: 44px; height: 44px; border-radius: 12px; border: 2px solid #F3F4F6;
|
||||
background: #F8F9FA; font-size: 22px; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.emoji-option.active { border-color: #F97316; background: #FFF0E6; transform: scale(1.05); }
|
||||
|
||||
.text-input, .text-area {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid #E5E7EB; border-radius: 10px;
|
||||
padding: 12px 14px; font-size: 14px; color: #18191C; outline: none; font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.text-input:focus, .text-area:focus { border-color: #F97316; }
|
||||
.text-area { resize: none; }
|
||||
.char-count { position: absolute; right: 4px; bottom: -18px; font-size: 11px; color: #C9CDD2; }
|
||||
|
||||
.type-picker { display: flex; gap: 10px; }
|
||||
.type-option {
|
||||
flex: 1; padding: 10px; border: 1px solid #E5E7EB; border-radius: 10px;
|
||||
background: white; font-size: 14px; color: #6B7280; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.type-option.active { border-color: #F97316; background: #FFF0E6; color: #F97316; font-weight: 600; }
|
||||
|
||||
.submit-section { padding: 0 20px; }
|
||||
.submit-btn {
|
||||
width: 100%; padding: 14px; background: #F97316; color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.submit-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.submit-btn:not(:disabled):hover { opacity: 0.9; }
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 12px 0 0; }
|
||||
</style>
|
||||
100
digital-avatar-app/src/views/MyProjects.vue
Normal file
100
digital-avatar-app/src/views/MyProjects.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="projects-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">我的项目</h1>
|
||||
</div>
|
||||
<span class="count-badge">{{ projects.length }}</span>
|
||||
</header>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<section class="projects-section">
|
||||
<div class="project-list" v-if="projects.length > 0">
|
||||
<div class="project-card" v-for="p in projects" :key="p.id">
|
||||
<div class="project-top">
|
||||
<div class="project-emoji">{{ p.emoji }}</div>
|
||||
<div class="project-head">
|
||||
<h2 class="project-name">{{ p.name }}</h2>
|
||||
<span class="role-badge" :class="p.role">{{ roleText(p.role) }}</span>
|
||||
</div>
|
||||
<span class="status-dot" :class="p.status"></span>
|
||||
</div>
|
||||
<p class="project-desc">{{ p.desc }}</p>
|
||||
<div class="project-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: p.progress + '%' }"></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ p.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">📁</span>
|
||||
<p class="empty-text">分身暂未参与任何项目</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface Project { id: string; emoji: string; name: string; role: string; desc: string; status: string; progress: number }
|
||||
const projects = ref<Project[]>([
|
||||
{ id: '1', emoji: '🚀', name: '会会增长计划', role: 'owner', desc: '负责品牌分身的对话策略与内容生成', status: 'active', progress: 72 },
|
||||
{ id: '2', emoji: '📊', name: '用户访谈分析', role: 'member', desc: '协助整理 200 份访谈记录并提炼洞察', status: 'active', progress: 45 },
|
||||
{ id: '3', emoji: '🎯', name: '私域运营 SOP', role: 'viewer', desc: '只读权限,跟进流程文档', status: 'paused', progress: 20 }
|
||||
])
|
||||
|
||||
const roleText = (r: string) => ({ owner: '负责人', member: '成员', viewer: '访客' }[r] || '成员')
|
||||
|
||||
const goBack = () => router.back()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.projects-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.count-badge { background: rgba(255,255,255,0.2); padding: 2px 12px; border-radius: 20px; font-size: 14px; }
|
||||
|
||||
.projects-section { padding: 16px 20px; }
|
||||
.project-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.project-card { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.project-top { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.project-emoji {
|
||||
width: 44px; height: 44px; border-radius: 12px; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.project-head { flex: 1; min-width: 0; }
|
||||
.project-name { font-size: 15px; font-weight: 600; margin: 0 0 4px; color: #18191C; }
|
||||
.role-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.role-badge.owner { background: #FFF0E6; color: #F97316; }
|
||||
.role-badge.member { background: #EFF6FF; color: #3B82F6; }
|
||||
.role-badge.viewer { background: #F3F4F6; color: #6B7280; }
|
||||
.status-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.active { background: #22C55E; }
|
||||
.status-dot.paused { background: #F59E0B; }
|
||||
.project-desc { font-size: 13px; color: #6B7280; line-height: 1.5; margin: 0 0 12px; }
|
||||
.project-progress { display: flex; align-items: center; gap: 10px; }
|
||||
.progress-bar { flex: 1; height: 6px; background: #F3F4F6; border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #F97316, #FB923C); border-radius: 3px; transition: width 0.4s ease; }
|
||||
.progress-text { font-size: 12px; color: #9398AE; flex-shrink: 0; }
|
||||
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 40px 20px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9398AE; margin: 0; }
|
||||
</style>
|
||||
304
digital-avatar-app/src/views/QaPairEdit.vue
Normal file
304
digital-avatar-app/src/views/QaPairEdit.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="qa-edit-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">{{ isEdit ? '编辑问答对' : '添加问答对' }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="form-section">
|
||||
<label class="field-label">问题</label>
|
||||
<textarea
|
||||
v-model="form.question"
|
||||
class="field-input"
|
||||
rows="3"
|
||||
placeholder="例如:你们的退款政策是什么?"
|
||||
></textarea>
|
||||
|
||||
<label class="field-label">标准答案</label>
|
||||
<textarea
|
||||
v-model="form.answer"
|
||||
class="field-input"
|
||||
rows="5"
|
||||
placeholder="输入该问题的标准回答"
|
||||
></textarea>
|
||||
|
||||
<div class="switch-row">
|
||||
<div class="switch-text">
|
||||
<span class="switch-title">启用</span>
|
||||
<span class="switch-desc">关闭后该问答对不参与分身作答</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="form.enabled" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-text">{{ error }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-cancel" @click="goBack">取消</button>
|
||||
<button class="btn-save" :disabled="saving" @click="save">保存</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
import { getQAPairs, createQAPair, updateQAPair } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAvatarStore()
|
||||
|
||||
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
|
||||
const qaId = computed(() => (route.params.qaId as string) || null)
|
||||
const isEdit = computed(() => !!qaId.value)
|
||||
|
||||
const form = reactive({ question: '', answer: '', enabled: true })
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const loadForEdit = async () => {
|
||||
if (!avatarId.value || !qaId.value) return
|
||||
try {
|
||||
const res: any = await getQAPairs(avatarId.value)
|
||||
const list: any[] = unwrapListData(res)
|
||||
const target = list.find((q) => q.id === qaId.value)
|
||||
if (target) {
|
||||
form.question = target.question
|
||||
form.answer = target.answer
|
||||
form.enabled = target.enabled !== false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
error.value = ''
|
||||
if (!avatarId.value) {
|
||||
error.value = '请先创建数字分身'
|
||||
return
|
||||
}
|
||||
if (!form.question.trim() || !form.answer.trim()) {
|
||||
error.value = '请填写问题和答案'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
question: form.question.trim(),
|
||||
answer: form.answer.trim(),
|
||||
enabled: form.enabled
|
||||
}
|
||||
if (isEdit.value && qaId.value) {
|
||||
await updateQAPair(avatarId.value, qaId.value, payload)
|
||||
} else {
|
||||
await createQAPair(avatarId.value, payload)
|
||||
}
|
||||
// 保存成功返回知识库管理页
|
||||
router.replace('/knowledge')
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.avatars.length) {
|
||||
await store.loadAvatars()
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await loadForEdit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qa-edit-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
color: #18191C;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.switch-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.switch-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.switch-desc {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
/* 开关 */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: #E5E7EB;
|
||||
border-radius: 24px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background: #F97316;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
color: #EF4444;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #F3F4F6;
|
||||
color: #6B7280;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
536
digital-avatar-app/src/views/SmsLogin.vue
Normal file
536
digital-avatar-app/src/views/SmsLogin.vue
Normal file
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="glow glow-top" aria-hidden="true" />
|
||||
<div class="glow glow-bottom" aria-hidden="true" />
|
||||
|
||||
<div class="login-content">
|
||||
<div class="brand">
|
||||
<div class="brand-logo" aria-hidden="true">
|
||||
<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="brand-grad" x1="0" y1="0" x2="88" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFB36B" />
|
||||
<stop offset="1" stop-color="#FF7A1A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="44" cy="44" r="42" fill="url(#brand-grad)" />
|
||||
<circle cx="44" cy="44" r="42" fill="none" stroke="#FFFFFF" stroke-opacity="0.5" stroke-width="2" />
|
||||
<rect x="26" y="30" width="36" height="32" rx="10" fill="#FFFFFF" />
|
||||
<circle cx="37" cy="46" r="3.5" fill="#FF7A1A" />
|
||||
<circle cx="51" cy="46" r="3.5" fill="#FF7A1A" />
|
||||
<rect x="36" y="53" width="16" height="3.5" rx="1.75" fill="#FF7A1A" />
|
||||
<rect x="42" y="18" width="4" height="10" rx="2" fill="#FFFFFF" />
|
||||
<circle cx="44" cy="16" r="4" fill="#FFFFFF" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="brand-title">会会数字分身</h1>
|
||||
<p class="brand-sub">你的专属 AI 数字分身</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换:默认账号密码 -->
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'password' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'password'"
|
||||
@click="activeTab = 'password'"
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'sms' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'sms'"
|
||||
@click="activeTab = 'sms'"
|
||||
>
|
||||
短信登录
|
||||
</button>
|
||||
<span class="tab-indicator" :class="activeTab" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录 -->
|
||||
<form v-if="activeTab === 'password'" class="form" @submit.prevent="onPwdLogin" novalidate>
|
||||
<div class="input-card">
|
||||
<input
|
||||
v-model="account"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
placeholder="手机号 / 会会账号"
|
||||
aria-label="账号"
|
||||
@input="errorMsg = ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-card">
|
||||
<input
|
||||
v-model="password"
|
||||
class="input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
aria-label="密码"
|
||||
@input="errorMsg = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="!canPwdLogin || loading">
|
||||
{{ loading ? '登录中...' : '登录 / 创建分身' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 短信验证码登录 -->
|
||||
<form v-else class="form" @submit.prevent="onSmsLogin" novalidate>
|
||||
<div class="input-card">
|
||||
<span class="prefix" aria-hidden="true">+86</span>
|
||||
<span class="divider" aria-hidden="true" />
|
||||
<input
|
||||
v-model="phone"
|
||||
class="input"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
aria-label="手机号"
|
||||
@input="onPhoneInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-card code-card">
|
||||
<input
|
||||
v-model="code"
|
||||
class="input"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入验证码"
|
||||
aria-label="验证码"
|
||||
@input="onCodeInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="code-btn"
|
||||
:disabled="!canSend || counting"
|
||||
@click="onSendCode"
|
||||
>
|
||||
{{ counting ? `${countdown}s 后重发` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="!canLogin || loading">
|
||||
{{ loading ? '登录中...' : '登录 / 创建分身' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="footer">
|
||||
<p class="footer-hint">
|
||||
{{ activeTab === 'password' ? '使用会会账号密码登录,首次登录将自动创建分身' : '未注册的手机号验证后将自动创建分身账号' }}
|
||||
</p>
|
||||
<p class="footer-agree">登录即代表同意《用户协议》与《隐私政策》</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const avatarStore = useAvatarStore()
|
||||
|
||||
const activeTab = ref<'password' | 'sms'>('password')
|
||||
|
||||
// 短信登录
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
|
||||
// 密码登录
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const counting = ref(false)
|
||||
const countdown = ref(60)
|
||||
let timer: any = null
|
||||
|
||||
const canSend = computed(() => /^1\d{10}$/.test(phone.value))
|
||||
const canLogin = computed(() => canSend.value && /^\d{4,6}$/.test(code.value))
|
||||
const canPwdLogin = computed(() => account.value.trim().length > 0 && password.value.length > 0)
|
||||
|
||||
const onPhoneInput = () => {
|
||||
phone.value = phone.value.replace(/\D/g, '').slice(0, 11)
|
||||
errorMsg.value = ''
|
||||
}
|
||||
const onCodeInput = () => {
|
||||
code.value = code.value.replace(/\D/g, '').slice(0, 6)
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
const startCountdown = () => {
|
||||
counting.value = true
|
||||
countdown.value = 60
|
||||
timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
counting.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const onSendCode = async () => {
|
||||
if (!canSend.value || counting.value) return
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await userStore.sendCode(phone.value)
|
||||
startCountdown()
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '验证码发送失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
const afterLogin = (res: any) => {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: res.huihui?.userId || res.user?.huihuiUserId || '',
|
||||
nickname: res.huihui?.nickname || res.user?.nickname || '',
|
||||
avatarUrl: res.huihui?.avatarUrl || res.user?.avatarUrl || ''
|
||||
})
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.replace(redirect)
|
||||
}
|
||||
|
||||
const onSmsLogin = async () => {
|
||||
if (!canLogin.value || loading.value) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res: any = await userStore.login(phone.value, code.value)
|
||||
afterLogin(res)
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '登录失败,请检查验证码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onPwdLogin = async () => {
|
||||
if (!canPwdLogin.value || loading.value) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res: any = await userStore.loginByPwd(account.value.trim(), password.value)
|
||||
afterLogin(res)
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '登录失败,请检查账号或密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding-top: calc(20px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
overflow: hidden;
|
||||
background: #fff6ec;
|
||||
animation: fadeIn 0.45s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(50px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.glow-top {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
top: -90px;
|
||||
right: -40px;
|
||||
background: rgba(255, 179, 107, 0.5);
|
||||
}
|
||||
|
||||
.glow-bottom {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
bottom: -120px;
|
||||
left: -110px;
|
||||
background: rgba(255, 205, 166, 0.55);
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
filter: drop-shadow(0 12px 24px rgba(255, 122, 26, 0.3));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #4a2511;
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #a9744f;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── 分段切换 ── */
|
||||
.tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: inset 0 1px 2px rgba(74, 37, 17, 0.05);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #a9744f;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #4a2511;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
width: calc(50% - 5px);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(74, 37, 17, 0.1);
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tab-indicator.password {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.tab-indicator.sms {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* ── 表单 ── */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
animation: formIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes formIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.input-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 56px;
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(74, 37, 17, 0.06),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.8);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.input-card:focus-within {
|
||||
border-color: rgba(255, 140, 66, 0.65);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(74, 37, 17, 0.08),
|
||||
0 0 0 3px rgba(255, 140, 66, 0.15),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.prefix {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #4a2511;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(74, 37, 17, 0.15);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
color: #4a2511;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #a9744f;
|
||||
}
|
||||
|
||||
.code-card {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
flex-shrink: 0;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #ff8c42;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.code-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.code-btn:disabled {
|
||||
background: #f3d2c3;
|
||||
color: #fff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: -4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ff8c42 0%, #f96e1c 100%);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 20px rgba(255, 140, 66, 0.45);
|
||||
transition: transform 0.15s, opacity 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
background: #e5e7eb;
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #a9744f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-agree {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #c9a88e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
378
digital-avatar-app/src/views/TokenCharge.vue
Normal file
378
digital-avatar-app/src/views/TokenCharge.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="token-charge-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">Token 充值</h1>
|
||||
<div class="header-right"></div>
|
||||
</header>
|
||||
|
||||
<!-- 当前余额 -->
|
||||
<section class="balance-section">
|
||||
<div class="balance-card">
|
||||
<span class="balance-label">当前余额</span>
|
||||
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
|
||||
<span class="balance-unit">Token</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 充值套餐 -->
|
||||
<section class="plans-section">
|
||||
<h3 class="section-title">选择充值套餐</h3>
|
||||
<div class="plan-list">
|
||||
<div
|
||||
class="plan-card"
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
:class="{ selected: selectedPlan?.id === plan.id }"
|
||||
@click="selectedPlan = plan"
|
||||
>
|
||||
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
||||
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div>
|
||||
<div class="plan-unit">Token</div>
|
||||
<div class="plan-price">¥{{ plan.price }}</div>
|
||||
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 支付方式 -->
|
||||
<section class="payment-section">
|
||||
<h3 class="section-title">支付方式</h3>
|
||||
<div class="payment-list">
|
||||
<div
|
||||
class="payment-card"
|
||||
:class="{ selected: paymentMethod === 'wechat' }"
|
||||
@click="paymentMethod = 'wechat'"
|
||||
>
|
||||
<span class="payment-icon">💚</span>
|
||||
<span class="payment-name">微信支付</span>
|
||||
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
||||
</div>
|
||||
<div
|
||||
class="payment-card"
|
||||
:class="{ selected: paymentMethod === 'alipay' }"
|
||||
@click="paymentMethod = 'alipay'"
|
||||
>
|
||||
<span class="payment-icon">💙</span>
|
||||
<span class="payment-name">支付宝</span>
|
||||
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 充值按钮 -->
|
||||
<section class="checkout-section">
|
||||
<button
|
||||
class="checkout-btn"
|
||||
:class="{ disabled: !selectedPlan }"
|
||||
:disabled="!selectedPlan"
|
||||
@click="doCharge"
|
||||
>
|
||||
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 当前余额
|
||||
const currentBalance = ref<number>(1250)
|
||||
|
||||
// 充值套餐
|
||||
const plans = ref<Array<{
|
||||
id: string
|
||||
amount: number
|
||||
price: number
|
||||
badge?: string
|
||||
desc?: string
|
||||
}>>([])
|
||||
|
||||
// 选中的套餐
|
||||
const selectedPlan = ref<any>(null)
|
||||
|
||||
// 支付方式
|
||||
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
||||
|
||||
// 从后端加载余额与套餐
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const b: any = await getTokenBalance()
|
||||
currentBalance.value = b?.balance ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
try {
|
||||
const p: any = await getRechargePlans()
|
||||
plans.value = p || []
|
||||
} catch (e) {
|
||||
console.error('加载套餐失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行充值(写入后端)
|
||||
const charging = ref(false)
|
||||
const doCharge = async () => {
|
||||
if (!selectedPlan.value || charging.value) return
|
||||
charging.value = true
|
||||
try {
|
||||
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
|
||||
const res: any = await chargeToken(selectedPlan.value.id)
|
||||
currentBalance.value = res?.balance ?? currentBalance.value
|
||||
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`)
|
||||
} catch (e) {
|
||||
alert('充值失败,请重试')
|
||||
} finally {
|
||||
charging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.token-charge-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
/* 当前余额 */
|
||||
.balance-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.balance-unit {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 充值套餐 */
|
||||
.plans-section {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
position: relative;
|
||||
padding: 20px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
border: 2px solid #EDEEF1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plan-card:hover {
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.plan-card.selected {
|
||||
border-color: #F97316;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 0 12px 0 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #18191C;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plan-unit {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #F97316;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plan-desc {
|
||||
font-size: 11px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
/* 支付方式 */
|
||||
.payment-section {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.payment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.payment-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #EDEEF1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.payment-card:hover {
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.payment-card.selected {
|
||||
border-color: #F97316;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.payment-name {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.payment-check {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 充值按钮 */
|
||||
.checkout-section {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.checkout-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkout-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.checkout-btn.disabled {
|
||||
background: #D1D5DB;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkout-btn.disabled:hover {
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
21
digital-avatar-app/tsconfig.json
Normal file
21
digital-avatar-app/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
25
digital-avatar-app/vite.config.ts
Normal file
25
digital-avatar-app/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// 后端 API 代理目标(仅 dev / vite 代理生效,生产构建走 nginx 或 __APP_CONFIG__.apiBase)
|
||||
// 默认指向已部署的远端后端 192.168.1.188:8011;
|
||||
// 若在本机另起后端,可在启动前覆盖:export VITE_API_PROXY_TARGET=http://localhost:8000
|
||||
const API_PROXY_TARGET = process.env.VITE_API_PROXY_TARGET || 'http://192.168.1.188:8011'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_PROXY_TARGET,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
96
digital-avatar-app/工作总结-2026-07-08.md
Normal file
96
digital-avatar-app/工作总结-2026-07-08.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 数字分身管理系统 · 今日工作总结
|
||||
|
||||
**日期**:2026-07-08
|
||||
**负责人**:Senior Developer(高级开发工程师)
|
||||
**项目目录**:`digital-avatar-app`
|
||||
**技术栈**:Vue3 + Vite + TypeScript + Pinia + Vue Router / FastAPI + SQLAlchemy + SQLite
|
||||
|
||||
---
|
||||
|
||||
## 一、项目背景与目标
|
||||
|
||||
- **产品线**:「会会」(HuiHui)AI 个人数字分身(云 SaaS 版)
|
||||
- **需求来源**:《AI 个人数字分身系统研发需求文档(PRD)》+ Ardot 设计稿(9 个页面)
|
||||
- **部署形态**:uni-app 封装为 App / 微信小程序 / H5(当前阶段先实现 Web 版,验证业务闭环)
|
||||
- **核心目标**:打通「创建分身 → 管理分身 → 工具/授权/充值」的完整用户路径,并完成前后端真实数据贯通
|
||||
|
||||
---
|
||||
|
||||
## 二、今日完成的工作
|
||||
|
||||
### 1. 修复页面空白 & 首页改造
|
||||
- 定位并修复页面空白三大根因:Pinia store 文件缺失、4 个视图文件为空(0 字节)、`main.ts` 导入了不存在的 store
|
||||
- 将首页(`/`)改为「创建分身」引导页(渐变区 + 功能亮点卡片 + CTA)
|
||||
- 首页「开始创建」CTA 接入 4 步向导(原为死 `alert`)
|
||||
|
||||
### 2. 创建分身 4 步向导
|
||||
- 步骤:① 基础信息 → ② 形象风格(emoji + 回复风格)→ ③ 灵魂配置(创造力/严谨度/幽默感滑块 + 回复长度 + 系统提示词)→ ④ 确认创建
|
||||
- 数据流打通:向导提交 → `avatarStore.addAvatar()` → `router.push('/avatar/manage')` → 管理页展示刚创建的分身(头像用所选 emoji)
|
||||
- 管理页右上角新增「+创建」入口,可随时再建
|
||||
|
||||
### 3. 分身工具 4 个子页面
|
||||
- 新增:**分身名片 / 分身人脉 / 我的项目 / 创建组织** 四个页面
|
||||
- 管理页「分身工具」区 4 个入口从 `console.log` 改为真实 `router.push` 跳转
|
||||
- 底部导航栏 3 入口(我的分身 / 授权管理 / Token),并在首页与编辑页自动隐藏,避免遮挡内容
|
||||
|
||||
### 4. FastAPI 后端搭建(核心交付)
|
||||
- **统一响应包装** `{code, message, data}`(`responses.py` 提供 `ok/fail`),与前端拦截器解包对齐
|
||||
- **数据模型**:`Avatar / Authorization / Organization / TokenAccount / TokenPlan`,`to_dict()` 输出 camelCase 字段(displayName / photoUrl / createdAt …)匹配前端类型
|
||||
- **接口清单**(均挂 `/api` 前缀):
|
||||
- `GET/POST /avatar`、`GET/PUT/DELETE /avatar/:id`
|
||||
- `GET /token/balance`、`GET /token/plans`、`POST /token/charge`
|
||||
- `GET/PUT /avatar/:id/authorizations`
|
||||
- `GET/POST /organizations`
|
||||
- **种子数据**:默认余额 1250、4 个充值套餐、1 个分身、3 条授权、2 个组织
|
||||
- 依赖装在隔离 venv:`/Users/yqq/.workbuddy/binaries/python/envs/default`
|
||||
|
||||
### 5. 前后端接通(端到端跑通)
|
||||
- `src/api/index.ts`:响应拦截器解包 `{code,message,data}`(code===200 返回 data);新增 `chargeToken`、`createOrganization`
|
||||
- `src/store/avatar.ts`:改为异步 `loadAvatars() / loadTokenBalance() / addAvatar()` 调真实接口
|
||||
- `AvatarManage`:Token 余额 / 分身卡片来自后端
|
||||
- `AvatarCreate`:`submit` 改 async 调 `addAvatar`(含 submitting 态)
|
||||
- `AuthorizationManage`:拉取 + 切换授权状态写后端
|
||||
- `TokenCharge`:`loadData()` 拉余额/套餐,`doCharge` 真实充值(余额回写)
|
||||
- `CreateOrg`:`submit` 调 `createOrganization`(含 submitting 态)
|
||||
|
||||
---
|
||||
|
||||
## 三、验证结果
|
||||
|
||||
| 验证项 | 结果 |
|
||||
|--------|------|
|
||||
| 后端接口 curl(含写操作) | 全部通过;充值 1250 → 2250 正确回写 |
|
||||
| Vite 代理 `/api/* → 8000` | 正常 |
|
||||
| 前端改动模块 Vite 转译 | 0 报错 |
|
||||
| DB 状态 | 已重置为干净种子态(balance 1250,1 分身) |
|
||||
| 生产构建(`vite build`) | 打包成功(主包 gzip 39KB / 1.43s) |
|
||||
|
||||
> ⚠️ 注:`npm run build` 中的 `vue-tsc` 因旧版本与 Node 22 不兼容(patch TS 内部字符串失败)报错,属工具链问题,非代码错误;已用 `vite build` 跳过类型检查确认打包无误。
|
||||
|
||||
---
|
||||
|
||||
## 四、当前架构(见附图)
|
||||
|
||||
端到端链路:**会会多端(App/小程序/H5)→ Vue3 SPA → Vite 代理 /api → FastAPI → SQLite**
|
||||
|
||||
---
|
||||
|
||||
## 五、遗留 & 下一步
|
||||
|
||||
1. **分身名片 / 人脉 / 项目** 3 个子页仍用前端 mock,待接真实接口(如 `getOrganizationList` 等)
|
||||
2. **编辑分身 / 删除分身** 接口待补全并接入
|
||||
3. **uni-app 封装**(App / 微信小程序 / H5)待启动,复用当前 Web 版组件
|
||||
4. **生产级类型检查**:升级 `vue-tsc` 或调整构建脚本,消除 Node 22 兼容告警
|
||||
5. **鉴权与多租户**:当前后端为单用户种子态,需补充用户登录与数据隔离
|
||||
|
||||
---
|
||||
|
||||
## 六、可复用模式(建议沉淀为 Skill)
|
||||
|
||||
「Vue3 + Vite 前端 / FastAPI 后端」脚手架约定:
|
||||
- Vite `server.proxy['/api'] → localhost:8000`
|
||||
- 后端统一 `{code, message, data}` 响应
|
||||
- 前端响应拦截器解包(code===200 返回 data)
|
||||
- SQLAlchemy 模型 `to_dict()` 输出 camelCase 字段,对齐前端 TS 类型
|
||||
|
||||
> 说明:本轮附带的 `ardot-design-core` / `ardot-design-router` 为 Ardot 画布操作型技能,依赖 Ardot MCP(当前环境未连接),故其设计规范以「橙色主题 / 卡片层次 / 渐进式向导路由」形式落地为 Vue 代码,未在画布上直接设计。
|
||||
169
docs/production-interface-inventory.md
Normal file
169
docs/production-interface-inventory.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# 生产环境接口清单
|
||||
|
||||
更新时间:2026-06-23
|
||||
|
||||
本文档整理的是本项目当前代码里会主动调用的外部生产环境接口,以及当前运行环境通过系统配置接口读到的真实生产地址。
|
||||
|
||||
## 当前生产配置
|
||||
|
||||
- 认证服务基址:`https://99hui.com/api/usercenter`
|
||||
- 新闻业务基址:`https://99hui.com/api/huihuibusiness`
|
||||
- 文件上传基址:`https://99hui.com/api/filecenter`
|
||||
说明:代码通过把 `huihuibusiness` 替换成 `filecenter` 推导得到。
|
||||
- 当前默认 AI 模型:
|
||||
- 模型名:`阿里千问大模型`
|
||||
- 提供方:`qianwen`
|
||||
- API Base URL:`https://dashscope.aliyuncs.com/compatible-mode/v1`
|
||||
- 模型版本:`qwen-plus`
|
||||
|
||||
## 认证服务
|
||||
|
||||
基址:`https://99hui.com/api/usercenter`
|
||||
|
||||
### `POST /open/login/token`
|
||||
|
||||
- 用途:虚拟用户账号密码登录,换取 `access_token`
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:149)
|
||||
- [system.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/api/endpoints/system.py:86)
|
||||
|
||||
### `PATCH /v2/users/current`
|
||||
|
||||
- 用途:同步修改目标平台用户资料
|
||||
- 涉及字段:昵称、真实姓名、性别、头像、简介、邮箱
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:946)
|
||||
|
||||
## 新闻业务服务
|
||||
|
||||
基址:`https://99hui.com/api/huihuibusiness`
|
||||
|
||||
### `GET /app/user/info`
|
||||
|
||||
- 用途:登录成功后探测用户所属组织 `orgId`
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250)
|
||||
|
||||
### `GET /open/user/info`
|
||||
|
||||
- 用途:登录成功后探测用户所属组织 `orgId`
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250)
|
||||
|
||||
### `GET /user/info`
|
||||
|
||||
- 用途:登录成功后探测用户所属组织 `orgId`
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250)
|
||||
|
||||
### `GET /news/list`
|
||||
|
||||
- 用途:会话有效性校验
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:323)
|
||||
|
||||
### `GET /business/square/list`
|
||||
|
||||
- 用途:
|
||||
- 统计今日文章数量
|
||||
- 拉取今日广场文章
|
||||
- 拉取历史广场文章
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:350)
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:483)
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:620)
|
||||
|
||||
### `GET /news/{article_id}`
|
||||
|
||||
- 用途:校验文章是否存在、正文是否足够长
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:396)
|
||||
|
||||
### `PATCH /news/read/{news_id}`
|
||||
|
||||
- 用途:模拟用户阅读新闻
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:711)
|
||||
|
||||
### `POST /message/comment`
|
||||
|
||||
- 用途:发表评论
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:734)
|
||||
|
||||
### `GET /message/comment`
|
||||
|
||||
- 用途:获取评论列表,为回复链路提供上下文
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:759)
|
||||
|
||||
### `POST /message/comment/reply`
|
||||
|
||||
- 用途:回复评论
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:747)
|
||||
|
||||
### `POST /message/praise`
|
||||
|
||||
- 用途:
|
||||
- 点赞
|
||||
- 收藏
|
||||
说明:当前收藏复用了点赞接口
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:771)
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:784)
|
||||
|
||||
### `GET /news/share/wechat/{news_id}`
|
||||
|
||||
- 用途:转发前的微信分享预热调用
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:796)
|
||||
|
||||
### `POST /points/forward/news/{org_id}`
|
||||
|
||||
- 用途:真正执行转发积分动作
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:802)
|
||||
|
||||
### `DELETE /message/praise/cancel`
|
||||
|
||||
- 用途:取消点赞 / 取消收藏
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:830)
|
||||
|
||||
### `DELETE /message/comment/{news_id}/{comment_id}`
|
||||
|
||||
- 用途:删除评论
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:865)
|
||||
|
||||
## 文件服务
|
||||
|
||||
基址:`https://99hui.com/api/filecenter`
|
||||
|
||||
### `POST /fileUpload`
|
||||
|
||||
- 用途:上传用户头像到平台文件中心
|
||||
- 代码位置:
|
||||
- [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:1002)
|
||||
|
||||
## AI 模型服务
|
||||
|
||||
当前默认基址:`https://dashscope.aliyuncs.com/compatible-mode/v1`
|
||||
|
||||
### `POST /chat/completions`
|
||||
|
||||
- 用途:
|
||||
- 生成人格
|
||||
- 生成评论
|
||||
- 生成回复
|
||||
- 测试模型连通性
|
||||
- 代码位置:
|
||||
- [ai_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/ai_service.py:70)
|
||||
- [ai_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/ai_service.py:215)
|
||||
|
||||
## 说明
|
||||
|
||||
- `resource.99hui.com` 当前主要出现在头像上传成功后的返回 URL 中,属于资源访问域名,不是本项目后端主动调用的 API。
|
||||
- AI 服务基址不是写死生产地址,而是读数据库里的模型配置;如果后台切换默认模型,这一节需要随之更新。
|
||||
- 当前系统配置里 `platform_org_id` 为空,这和“部分用户点立即互动时取不到文章列表”现象高度相关,建议后续继续排查组织信息自动发现链路。
|
||||
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 10m;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<span class="page-title-bar">{{ currentTitle }}</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<!-- 右上角消息通知组件暂时注释 -->
|
||||
<div class="online-badge">
|
||||
<span class="pulse"></span>
|
||||
<span>{{ onlineUsers }} 在线</span>
|
||||
|
||||
@@ -100,7 +100,7 @@ const filters = reactive({ keyword: '', interact_type: null, status: null, start
|
||||
|
||||
const typeMap = { comment: '评论', reply: '回复', like: '点赞', collect: '收藏', forward: '转发' }
|
||||
const typeTagType = { comment: '', reply: 'info', like: 'success', collect: 'warning', forward: 'danger' }
|
||||
const statusTagType = { 0: 'info', 1: 'success', 2: 'danger' }
|
||||
const statusTagType = { 0: 'info', 1: 'success', 2: 'danger', 3: 'warning' }
|
||||
|
||||
function onDateChange(v) {
|
||||
filters.start_date = v?.[0] || ''; filters.end_date = v?.[1] || ''
|
||||
@@ -171,7 +171,7 @@ async function handleCancel(row) {
|
||||
const res = await cancelInteraction(row.id)
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
ElMessage.success('取消成功')
|
||||
await loadInteractions()
|
||||
await load()
|
||||
} else {
|
||||
ElMessage.error(res.message || '取消失败')
|
||||
}
|
||||
|
||||
29
overview-avatar-default-2026-07-21.md
Normal file
29
overview-avatar-default-2026-07-21.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 形象风格默认使用当前用户头像
|
||||
|
||||
## 已完成
|
||||
- 在创建数字分身的 **Step 2「形象风格」** 中,**「选择形象」默认选中当前用户的会会头像**。
|
||||
- 新增「使用我的头像」选项卡片;点击后沿用 `avatarUrl` 作为分身形象。
|
||||
- 表情形象选项仍可切换;切换为表情后自动清空 `photoUrl`,最终创建的分身不会误用用户头像。
|
||||
|
||||
## 关键改动
|
||||
- `digital-avatar-app/src/views/AvatarCreate.vue`
|
||||
- 新增 `userAvatarUrl` 计算属性。
|
||||
- 新增 `usePhoto` 响应式状态;初始化时若用户有头像则默认启用。
|
||||
- Step 2 新增「使用我的头像」卡片 + 表情网格。
|
||||
- 确认页与提交按 `usePhoto` 决定是否使用 `photoUrl`。
|
||||
|
||||
## 构建与部署
|
||||
- 本地 `./node_modules/.bin/vite build` 通过(绕过已知的 vue-tsc 兼容问题)。
|
||||
- 部署路径:`dist/` → `geo-server:/root/avatar-test/run-dist/` → `docker cp avatar-test-frontend:/usr/share/nginx/html/`。
|
||||
- 测试环境地址:`http://192.168.1.188:8099`。
|
||||
- 已验证线上 `AvatarCreate-Bw_iuiqc.js` 包含新的 `usePhoto` 逻辑与"使用我的头像"文案,首页 HTTP 200。
|
||||
|
||||
## 设计稿
|
||||
- Ardot 设计稿「会会数字分身-形象风格(默认头像)」:
|
||||
- 文件地址:https://ardot.tencent.com/file/706264845036089
|
||||
- 沿用 Glass Sunset Warm 色系(`#FFF6EC` 背景、`#FF8C42` 强调色、`#4A2511` 文字)。
|
||||
- 展示 Step 2 默认选中「使用我的头像」、表情备选网格、回复风格分段选择。
|
||||
|
||||
## 后续注意
|
||||
- 当前仅在 Web H5 实现;uniapp 壳内若从 native 注入 `avatarUrl`,`avatarStore.userProfile` 已优先覆盖,逻辑自动生效。
|
||||
- 表情选项和头像选项为互斥;用户可在创建流程中随时切换。
|
||||
75
overview-avatar-user-isolation-2026-07-21.md
Normal file
75
overview-avatar-user-isolation-2026-07-21.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 会会数字分身 · 用户资料与分身隔离落地说明
|
||||
|
||||
## 问题
|
||||
用户登录成功后,发现页面显示"已有数字分身",且没有看到当前登录账号的头像/昵称;同时希望在创建数字分身时直接沿用当前会会账号的名称和头像。
|
||||
|
||||
## 根因
|
||||
1. **后端 `GET /api/avatar` 全局返回所有分身**,没有按用户隔离;且 `backend/main.py` 的 `seed()` 在数据库为空时会插入一个名为"我的数字分身"的种子记录。任何新登录用户都会看到它。
|
||||
2. 用户头像/昵称虽然已经在登录响应中返回并落库存入 `users` 表,但**仪表盘没有展示**。
|
||||
3. `AvatarCreate.vue` 只预填了昵称到 `displayName`,没有 `photoUrl` 字段,也没有头像预览。
|
||||
|
||||
## 改动
|
||||
|
||||
### 后端:分身按用户隔离
|
||||
- `backend/models.py`
|
||||
- `Avatar` 新增 `owner_id` 字段(会会 `huihui_user_id`),索引,默认空字符串。
|
||||
- `to_dict()` 暴露 `ownerId`。
|
||||
- `backend/database.py`
|
||||
- 在 `_try_add_columns` 中加入 `("avatars", "owner_id", "VARCHAR DEFAULT ''")`,容器启动时自动给已有 SQLite 表加列。
|
||||
- `backend/routers/avatars.py`
|
||||
- 新增 `_resolve_user()` 从 `Authorization: Bearer <app_token>` 解析当前用户。
|
||||
- `GET /api/avatar` 仅返回 `owner_id == user.huihui_user_id` 的分身;无/无效 token 返回空列表。
|
||||
- `POST /api/avatar` 创建时写入当前用户的 `owner_id`。
|
||||
|
||||
### 前端:仪表盘展示用户资料头
|
||||
- `src/views/AvatarManage.vue`
|
||||
- 引入 `useUserStore`。
|
||||
- 顶部新增用户资料头:圆形头像(`userStore.user.avatarUrl`,无则 emoji 回退)+ 昵称 + 登录状态/手机号。
|
||||
- 分身卡片优先显示真实头像 `photoUrl`,无则回退 emoji。
|
||||
|
||||
### 前端:创建分身预填名称与头像
|
||||
- `src/views/AvatarCreate.vue`
|
||||
- 表单新增 `photoUrl`。
|
||||
- `onMounted` 从 `avatarStore.userProfile || userStore.user` 预填 `name`、`displayName`、`photoUrl`。
|
||||
- Step1 新增"分身头像"预览区与"已沿用你的会会头像"提示。
|
||||
- 确认页显示用户头像(或 emoji 回退)。
|
||||
- 提交时携带 `photoUrl`。
|
||||
- 引导页"已有数字分身?"文案改为 `v-if="avatarStore.avatars.length > 0"`,避免新用户被误导。
|
||||
|
||||
### Ardot 视觉稿
|
||||
- 新建「会会数字分身-仪表盘与创建页」(`fileId: 706241841026616`)。
|
||||
- 包含两屏:
|
||||
1. 数字分身管理仪表盘(状态栏、橙头、用户资料头、Token 余额、分身卡片、工具入口)。
|
||||
2. 创建数字分身 Step1(头像预览、预填名称/显示名称、描述、下一步)。
|
||||
- 沿用 Glass Sunset Warm 品牌色系(`#FFF6EC` 背景、`#FF8C42` 主色、`#4A2511` 文字)。
|
||||
|
||||
## 部署
|
||||
- 前端:`./node_modules/.bin/vite build` → `rsync dist/` → `docker cp` 进 `avatar-test-frontend` → restart。
|
||||
- 后端:`rsync models.py / database.py / routers/avatars.py` → `docker cp` 进 `avatar-test-backend` → restart;启动时自动迁移 `owner_id` 列。
|
||||
|
||||
## 验证结果(容器内实测)
|
||||
| 检查项 | 结果 |
|
||||
|---|---|
|
||||
| `owner_id` 列已迁移 | ✅ |
|
||||
| 种子分身 `owner_id=''` | ✅ |
|
||||
| 无 auth `GET /api/avatar` | `{data:[],total:0}` ✅ |
|
||||
| 登录用户 `GET /api/avatar` | `{data:[],total:0}`(种子隐藏)✅ |
|
||||
| 创建分身写入 `ownerId` + `photoUrl` | ✅ |
|
||||
| 创建后本人列表只含 1 条 | ✅ |
|
||||
| 删除后列表回归空 | ✅ |
|
||||
|
||||
## 用户现在体验
|
||||
1. 登录后进入 `/#/`(创建引导)或 `/#/avatar/manage`(仪表盘),**顶部会显示当前会会账号的头像和昵称**。
|
||||
2. 新用户首次登录时,**不会看到任何"已有"分身**(列表为空)。
|
||||
3. 点"立即创建一个"进入创建流程,**名称和头像已经自动预填**了当前用户的会会昵称和头像。
|
||||
4. 创建成功后,仪表盘只显示用户自己创建的分身。
|
||||
|
||||
## 访问地址
|
||||
测试环境(同网段/隧道可达):
|
||||
- 创建引导页:`http://192.168.1.188:8099/#/`
|
||||
- 仪表盘:`http://192.168.1.188:8099/#/avatar/manage`
|
||||
- 登录页:`http://192.168.1.188:8099/#/login/sms`
|
||||
|
||||
## 后续可选
|
||||
- 生产环境切到 `99hui.com` 后可真正下发短信;当前 `fat-open` 预发环境建议继续用账号密码登录。
|
||||
- 可在创建页支持用户上传/裁剪自定义头像,替代目前直接沿用会会头像的方案。
|
||||
40
overview-deploy-2026-07-17.md
Normal file
40
overview-deploy-2026-07-17.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 数字分身测试环境部署 + 修复概览(2026-07-17)
|
||||
|
||||
## 本次动作:把上一轮改动真正发到测试环境
|
||||
测试环境:`geo-server`(192.168.1.188) 上 `/root/avatar-test`,docker-compose 跑
|
||||
`avatar-backend`(:8011) + `avatar-frontend`(:8099)。
|
||||
|
||||
访问地址:**http://192.168.1.188:8099/**(短信登录页:`/#/login/sms`)
|
||||
|
||||
已上线内容(上一轮你质疑的"创建覆盖/删除/去掉会会资料/真实短信"相关代码):
|
||||
- `AvatarManage.vue`:去掉"我的会会资料",改为全部分身列表,每张带删除+二次确认
|
||||
- `store/avatar.ts`:新增 `removeAvatar`
|
||||
- `backend/routers/avatars.py`:删除级联清理(知识库/切片/问答对/授权)
|
||||
- `backend/routers/huihui_auth.py`:真实生产调用优先,`DEV_MOCK` 仅兜底
|
||||
|
||||
## 端到端验证(已通过)
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| 后端 health | ✅ |
|
||||
| 分身列表 `GET /api/avatar` | ✅ 返回已 seed 分身 |
|
||||
| 短信发码(开发态) | ✅ 回显 `devCode` |
|
||||
| 短信登录→`/me` | ✅ 落库 dev 用户 + 会话 |
|
||||
| 删除级联 | ✅ 创建→删除→GET 404 |
|
||||
| 前端页面 `:8099` | ✅ 稳定 Up,`<title>会会数字分身</title>` |
|
||||
|
||||
## 部署中踩的两个坑(已修复,记进项目记忆)
|
||||
1. **环境变量被 rsync 覆盖**:本机 compose 不含 env,把远端原 `HUIHUI_DEV_MOCK`/`EMBEDDING_*` 丢了。
|
||||
修复:从运行容器 `docker inspect` 导出 4 个变量到远端 `.env`,compose 改用 `env_file` 引用;`.env` 加进 `.dockerignore`;本机同步加 `env_file`+`.gitignore` 防再丢。
|
||||
2. **前端容器反复 Restarting**:nginx 1.31.3 写 pid 文件被测试服务器 Docker 的 **seccomp 拦截 `pwrite`** 致命退出(老版 nginx 用 `write()` 且只告警,故之前 46h 正常)。
|
||||
修复:前端 `Dockerfile` 锁定 `nginx:1.28-alpine`(远端已缓存,无需联网);`nginx.conf` 改为完整主配置覆盖 `/etc/nginx/nginx.conf`。
|
||||
|
||||
## 关于"参考会会接口文档"(真实短信仍待你提供)
|
||||
- 仓库 `docs/production-interface-inventory.md` **只文档化了 `/open/login/token`(密码登录),没有短信端点**。
|
||||
- 已实现的真实生产调用:短信**登录**复用文档化的 `/open/login/token`,仅 `loginType/grantType=sms`(与会会开放平台一致范式);**发送验证码**端点走可配置 `HUIHUI_SMS_SEND_PATH`(默认 `/open/login/sms/send` 在生产返回 404)。
|
||||
- 仍阻塞:会会**真实短信发送端点路径**(如 `/open/sms/send` 等)+ 开放平台凭证(appId/accessId/accessSecret/clientCode)未到位。你提供后,关掉 `HUIHUI_DEV_MOCK`、写进 compose env、重建后端即可真接通会会生产库并同步用户资料。
|
||||
|
||||
## 改动文件
|
||||
- `digital-avatar-app/Dockerfile`(锁 nginx 1.28-alpine)
|
||||
- `digital-avatar-app/nginx.conf`(完整主配置,覆盖默认 nginx.conf)
|
||||
- `digital-avatar-app/docker-compose.yml`(backend 加 `env_file: - .env`)
|
||||
- `digital-avatar-app/.gitignore`、`digital-avatar-app/backend/.dockerignore`(忽略 `.env`)
|
||||
31
overview-huihui-auth-2026-07-21.md
Normal file
31
overview-huihui-auth-2026-07-21.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 会会用户体系打通 · 部署与验证概览
|
||||
|
||||
## 完成内容
|
||||
将 `digital-avatar-app` 的短信验证码登录从「DEV_MOCK 模拟」切换为**真实会会开放平台对接**,并部署到测试环境 `geo-server:/root/avatar-test`。
|
||||
|
||||
## 关键改动
|
||||
1. **`backend/routers/huihui_auth.py` 重写**
|
||||
- 真实端点:发码 `POST /open/mobile/sms/code`(query 参数)、登录 `POST /open/login/token`(formData,`loginType=code`)。
|
||||
- 签名范式与会会官方 `sign(1).js` 逐字节一致:公共字段 `appId/accessId/timestamp(12h hh)/signType(MD5)/signVersion(1.0)/accessSecret/nonce` → 过滤空值 → 字典序 → `k=v&` → 末尾 `accessSecret=secretKey` → MD5/SHA256 大写。
|
||||
- 登录成功后建/链本地 `users` 表(按会会 userId 唯一),签发本系统 `app_token` 会话。
|
||||
2. **真实凭证入环境**
|
||||
- `geo-server:/root/avatar-test/.env`(及本地 `digital-avatar-app/.env`):`HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET`,`HUIHUI_DEV_MOCK=false`。
|
||||
- `.env` 已被 `.gitignore` 与 `backend/.dockerignore` 排除,不会入库/入镜像。
|
||||
3. **时区 bug 修复(关键)**
|
||||
- 容器默认 UTC,会会网关按**北京时间**校验时间戳 → 签名被拒("签名验证失败")。
|
||||
- `_get_timestamp()` 改为显式 `timezone(timedelta(hours=8))`,与容器系统时区解耦(不用 `ZoneInfo`,slim 镜像缺 tzdata)。
|
||||
|
||||
## 验证结果(测试环境)
|
||||
| 接口 | 结果 |
|
||||
|---|---|
|
||||
| `POST /api/huihui/sms/send`(13800000001) | 网关 `code=200 success, sent=true` ✅ |
|
||||
| `POST /api/huihui/sms/login`(假验证码) | 网关 `code=10000 验证码不正确`(签名已接受)✅ |
|
||||
| `GET /api/huihui/me`(无 token) | `401 未登录`(路由/鉴权门正常)✅ |
|
||||
| 前端 `:8099` → 后端 `:8000` | nginx `/api` 代理正常 ✅ |
|
||||
|
||||
## 待用户操作
|
||||
在浏览器打开 `http://192.168.1.188:8099` → 短信登录页 → 输入**本人会会绑定手机号** → 获取真实验证码 → 登录。后端将调会会网关校验并写入 `users` 表,前端拿到 `app_token` + 会会资料后跳转。这是唯一未自动化的步骤(需真实手机号+真实验证码)。
|
||||
|
||||
## 排查笔记
|
||||
- 测试服务器宿主 `:8000` 另跑 `ai-virtual-news-backend`;容器映射 `8011→8000`,本地冒烟测试必须打 `:8011`。
|
||||
- 部署前用独立脚本(managed venv + httpx)直连真实网关验证签名,避免盲部署。
|
||||
22
overview-login-page-2026-07-21.md
Normal file
22
overview-login-page-2026-07-21.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 会会数字分身登录页重设计 — 完成概览
|
||||
|
||||
## 做了什么
|
||||
- 在 Ardot 完成了「会会数字分身-登录页」高保真设计稿。
|
||||
- 按设计稿重写了前端页面 `digital-avatar-app/src/views/SmsLogin.vue`。
|
||||
- Vite 生产构建验证通过。
|
||||
|
||||
## 关键决策
|
||||
- 视觉风格:Glass Sunset Warm 品牌色(背景 `#FFF6EC`、主色 `#FF8C42`、文字 `#4A2511`)+ Vision OS Aurora 玻璃拟态。
|
||||
- 布局:移动端全屏,单栏内容包裹器,无侧边距,输入卡片和按钮通栏。
|
||||
- 品牌图形:内联渐变机器人 SVG,替代原有 emoji,更具数字分身识别度。
|
||||
- CTA 文案改为「登录 / 创建分身」,直接传达未注册手机号自动建号的业务规则。
|
||||
- 保留原组件全部业务逻辑:手机号/验证码校验、60s 倒计时、演示模式 banner、会会登录 API、登录成功后同步用户资料并跳转。
|
||||
|
||||
## 产物位置
|
||||
- Ardot 设计稿:`https://ardot.tencent.com/file/706196382431752`
|
||||
- 前端页面:`digital-avatar-app/src/views/SmsLogin.vue`
|
||||
- 设计截图:`/Users/yqq/Works/AiProject/huihuiSquare/.workbuddy/cache/ardot-shots/screenshot(2_1)-20260721_121632.png`
|
||||
|
||||
## 待跟进
|
||||
- 需要用户在测试环境用真实会会绑定手机号走完整登录链路(发真实短信 → 输入真码 → 进入首页),验证 UI 与后端新对接的会会用户体系端到端可用。
|
||||
- 可进一步用 HBuilderX 把前端构建产物更新到 uniapp 壳工程中,做真机视觉走查。
|
||||
42
overview-nickname-prefill-fix-2026-07-21.md
Normal file
42
overview-nickname-prefill-fix-2026-07-21.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 创建分身未预填登录昵称 — 根因与修复
|
||||
|
||||
## 现象
|
||||
登录成功后,进入「创建数字分身」,分身名称 / 显示名称没有默认填入当前登录用户的昵称;
|
||||
而仪表盘(数字分身管理)却正常显示该昵称。两者读数不一致。
|
||||
|
||||
## 根因(前端取值优先级 bug)
|
||||
`AvatarCreate.vue` 的 `onMounted` 原逻辑:
|
||||
```js
|
||||
const profile = avatarStore.userProfile || userStore.user
|
||||
const nick = profile?.nickname
|
||||
```
|
||||
- `avatarStore.userProfile` 是一个 **truthy 对象**(即使昵称为空字符串),会抢在真正含昵称的
|
||||
`userStore.user`(登录会话 + localStorage 持久化)之前被选中。
|
||||
- `src/main.ts` 在应用启动时执行 `setNativeProfile({ nickname: userStore.user.nickname || '' })`:
|
||||
当 localStorage 里是旧会话、昵称为空时,会把一个「空昵称对象」写进 `userProfile`。
|
||||
- 于是 `profile.nickname === ''`,创建页不预填;仪表盘直接读 `userStore.user` 所以照常显示。
|
||||
|
||||
DB 实证:`avatar-test` 容器内 `/app/avatar.db` 的 `users` 表确有真实昵称(如 `猜猜我是谁`)与真实头像
|
||||
URL —— 数据本身没问题,纯前端取值优先级问题。
|
||||
|
||||
## 修复
|
||||
文件:`digital-avatar-app/src/views/AvatarCreate.vue`
|
||||
1. `onMounted` 改为 **优先 `userStore.user`,`avatarStore.userProfile` 仅作兜底**:
|
||||
```js
|
||||
const real = userStore.user
|
||||
const native = avatarStore.userProfile
|
||||
const nick = real?.nickname || native?.nickname || ''
|
||||
const avatar = real?.avatarUrl || native?.avatarUrl || ''
|
||||
```
|
||||
2. `userAvatarUrl` 计算属性同样改为优先 `userStore.user.avatarUrl`。
|
||||
3. 兜底:本地两者皆空且已登录时,调用 `getCurrentUser()`(`GET /huihui/me`,读 DB 权威值)补拉真实昵称/头像。
|
||||
|
||||
## 部署与验证
|
||||
- `./node_modules/.bin/vite build` → 新 chunk `AvatarCreate-CNMENOcc.js`
|
||||
- 部署到测试环境 `geo-server`:`rsync dist/` → `docker cp` 进 `avatar-test-frontend:/usr/share/nginx/html/`
|
||||
- 容器内清理旧 AvatarCreate 孤儿 chunk(`Bw_iuiqc.js` 等多份历史残留)
|
||||
- 验证:首页 HTTP 200;线上 chunk 含 `displayName` 预填逻辑
|
||||
|
||||
## 经验
|
||||
前端「当前用户资料」应统一以 `userStore.user`(登录会话、localStorage 持久化)为权威源;
|
||||
`avatarStore.userProfile`(原生壳注入 / 启动桥接)是可空、可被空值覆盖的派生源,绝不能放在 `||` 左侧优先。
|
||||
45
overview-password-login-2026-07-21.md
Normal file
45
overview-password-login-2026-07-21.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 会会数字分身 · 短信收不到 + 账号密码登录
|
||||
|
||||
## 1. "收不到短信验证码" 是否接通?
|
||||
**已接通,但预发环境不下发真实短信。**
|
||||
|
||||
实测证据(直连会会网关):
|
||||
- 后端日志:用户的发码/登录请求已到达并 `200 OK`。
|
||||
- 会会原始响应:`{code:0, message:"验证码发送成功", ...}`
|
||||
- 签名算法、`appId/accessId/secret` 均正确,时间戳走 24h `HH`(避免"签名验证失败")。
|
||||
|
||||
**为什么收不到**:当前对接的是 `fat-open.99hui.com`(会会**预发/测试网关**)。
|
||||
它接收请求并返回"发送成功",但**不实际把短信下发到真实手机**(测试环境模拟发送)。
|
||||
这是环境限制,不是代码 bug。要真实收到短信需切到生产网关 `99hui.com/api/usercenter`(同套开放平台凭证通常通用,但生产是否真连短信通道需确认/由会会开启)。
|
||||
|
||||
## 2. 新增「账号密码登录」(默认方式)
|
||||
既然短信在测试环境不可达,新增可靠的账号密码登录,并设为默认标签。
|
||||
|
||||
### 后端 `backend/routers/huihui_auth.py`
|
||||
- 新增 `pwd_login` → `POST /api/huihui/pwd/login`
|
||||
- 调会会 `/open/login/token`,参数与会会开放平台一致:
|
||||
`username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false`
|
||||
- 复用 `_issue_session` 建/链本地 `users` 表并签发 `app_token`
|
||||
- 账号非 11 位手机号时不覆盖已有 `user.phone`
|
||||
|
||||
### 前端
|
||||
- `src/api/index.ts`:新增 `loginByPassword(account, password)` → `/huihui/pwd/login`
|
||||
- `src/store/user.ts`:新增 `loginByPwd` action
|
||||
- `src/views/SmsLogin.vue`:**分段切换** UI
|
||||
- 默认标签 **密码登录**(账号/手机号 + 密码)
|
||||
- 次选标签 **短信登录**(原手机号+验证码)
|
||||
|
||||
### 部署
|
||||
`vite build` → `rsync dist/` → `docker cp` 进 `avatar-test-frontend` 容器 → `docker restart`。
|
||||
|
||||
### 验证
|
||||
- 线上 SmsLogin chunk 含「密码登录」、api bundle 含「huihui/pwd/login」✅
|
||||
- 实时 `POST /huihui/pwd/login`(假密码)→ 会会返回 `账号或密码错误!`
|
||||
(说明签名已被接受、登录链路连通,仅密码错)✅
|
||||
|
||||
## 3. 你现在怎么登录
|
||||
打开 **http://192.168.1.188:8099/#/login/sms**(同网段/隧道):
|
||||
- **推荐:密码登录** —— 输入你的会会账号(手机号或用户名)+ 密码,点「登录 / 创建分身」。
|
||||
- 短信登录仅在会会生产网关真正下发短信时才有用;当前测试环境点了显示"发送成功"但手机收不到。
|
||||
|
||||
> 注:会会开放平台账号须已设置密码,否则密码登录会报「账号或密码错误」(可先在会会 App 内设置/重置密码)。
|
||||
37
overview-sms-login-live-2026-07-21.md
Normal file
37
overview-sms-login-live-2026-07-21.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 会会数字分身 · 真实短信验证码登录 — 落地说明(2026-07-21)
|
||||
|
||||
## 功能状态:✅ 已端到端上线(测试环境)
|
||||
|
||||
真实手机号 + 会会短信验证码登录现已完整可用,前端已部署、后端已对接会会开放平台(fat-open)。
|
||||
|
||||
## 访问地址
|
||||
- 登录页:`http://192.168.1.188:8099/#/login/sms`(需与本机同网段或通过隧道访问)
|
||||
- API 基址:前端经 nginx `/api` 代理至后端 `avatar-backend:8000`
|
||||
|
||||
## 使用流程(用户侧)
|
||||
1. 浏览器打开登录页,输入**本人真实手机号**(11 位)。
|
||||
2. 点「获取验证码」→ 触发会会开放平台真实短信下发。
|
||||
3. 手机收到 6 位验证码,填入。
|
||||
4. 点「登录 / 创建分身」→ 后端换会会 `access_token` + `userId`,自动建/链本地 `users` 表(按 `huihui_user_id` 唯一),签发本系统 `app_token` 作为会话并跳转到首页。
|
||||
|
||||
## 技术链路
|
||||
- 前端 `SmsLogin.vue` → `sendSmsCode(phone)` / `loginBySms(phone, code)`
|
||||
→ `POST /huihui/sms/send` `{phone}`、`POST /huihui/sms/login` `{phone, code}`
|
||||
- 后端 `routers/huihui_auth.py`:
|
||||
- 发码:`POST /open/mobile/sms/code`(query,公共字段签名,北京时间戳)
|
||||
- 登录:`POST /open/login/token`(formData,`loginType=code`、`isRegister=true`)
|
||||
- 签名范式与会会 `sign(1).js` 逐字节一致
|
||||
- 关键修复:容器用 `timezone(timedelta(hours=8))` 生成时间戳,规避会会网关按北京时间校验导致的"签名验证失败"。
|
||||
|
||||
## 本次变更
|
||||
1. `src/views/SmsLogin.vue`:移除「演示模式」误导性 banner 与 devCode 自动填入,纯真实模式;保留玻璃态 UI / 渐变 CTA / 倒计时等 Premium 视觉。
|
||||
2. 本地 `vite build` → `rsync dist/` 至 geo-server → `docker cp` 进 `avatar-test-frontend` 容器并重启,完成部署(前端容器已 3 天旧构建,现已更新)。
|
||||
|
||||
## 部署后验证
|
||||
- ✅ 新 `SmsLogin` chunk 已含「会会数字分身」文案(确认新前端生效)
|
||||
- ✅ `GET /api/huihui/me` 经代理返回 `401 未登录`(路由存活、代理通)
|
||||
- ✅ 后端 `:8011` 直连同结果
|
||||
|
||||
## 备注
|
||||
- 真实短信由会会 fat-open 网关下发,需用户本人手机号完成最后一步登录。
|
||||
- 如需我代为触发一次真实短信以验证会会集成,请提供接收验证码的手机号。
|
||||
44
overview-style-fix-2026-07-21.md
Normal file
44
overview-style-fix-2026-07-21.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 全站样式消失事故 — 根因与修复(2026-07-21)
|
||||
|
||||
## 现象
|
||||
用户反馈:线上 `http://192.168.1.188:8099`「样式都全没了」。
|
||||
|
||||
## 根因(部署脚本 bug,非业务代码)
|
||||
早前部署使用:
|
||||
```
|
||||
docker cp /root/avatar-test/run-dist/. avatar-test-frontend:/usr/share/nginx/html/
|
||||
```
|
||||
这个 `SRC/.` 写法**不可靠**——它把新 JS 拷进容器,却**漏拷了新 CSS**。
|
||||
结果是容器内变成「新 JS + 旧 CSS」组合:
|
||||
|
||||
- Vue `<style scoped>` 的样式靠 `data-v-xxxxxx` 作用域属性匹配;
|
||||
- 新 JS 渲染元素用 `data-v-732791c8`,但容器内唯一的 `AvatarCreate-CslCRBwi.css` 是旧构建、用 `data-v-8333551b`;
|
||||
- 二者 scope id 不一致 → 样式被浏览器静默丢弃 → **所有页面全站样式失效**。
|
||||
|
||||
本地 `dist` 同源构建是自洽的(JS scope == CSS scope),证明问题只发生在 `docker cp` 拷贝环节。
|
||||
|
||||
## 修复
|
||||
改用**显式子目录拷贝**(可靠):
|
||||
```
|
||||
docker cp /root/avatar-test/run-dist/assets/. avatar-test-frontend:/usr/share/nginx/html/assets/
|
||||
docker cp /root/avatar-test/run-dist/index.html avatar-test-frontend:/usr/share/nginx/html/index.html
|
||||
```
|
||||
整体覆盖后,各页面 JS/CSS 的 scope id 全部对齐:
|
||||
- AvatarCreate = `data-v-732791c8`(JS==CSS ✅)
|
||||
- AvatarManage = `data-v-007cc632`(JS==CSS ✅)
|
||||
- SmsLogin = `data-v-7851357e`(JS==CSS ✅)
|
||||
|
||||
## 验证(无头浏览器实测)
|
||||
- Playwright(chromium) 渲染 `:8099`:控制台 **0 报错**,页面 DOM 正常渲染。
|
||||
- computed style 实测:
|
||||
- `.hero` 背景 = `linear-gradient(135deg, rgb(249,115,22), rgb(251,146,60), rgb(253,186,116))`(暖色渐变生效)
|
||||
- `.feature-card` 背景 = `rgb(255,255,255)`(白卡生效)
|
||||
|
||||
→ 样式确凿恢复。
|
||||
|
||||
## 部署铁律(写入项目记忆)
|
||||
Vite SPA 部署到 docker nginx 容器,**禁止** `docker cp run-dist/. html/`。
|
||||
一律拆成 `assets/.` 与 `index.html` 两条显式拷贝;或先 `rm -rf assets/*` 再 cp。
|
||||
每次部署后核对「线上 JS scope id == 同页 CSS scope id」,否则 scoped 样式会静默失效而难以察觉。
|
||||
|
||||
测试地址:`http://192.168.1.188:8099`(建议硬刷新清掉旧 JS/CSS 缓存)。
|
||||
39
overview.md
Normal file
39
overview.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 数字分身管理页重设计 + 删除功能 + 短信登录真实生产改造
|
||||
|
||||
## 已完成
|
||||
|
||||
1. **数字分身管理页重设计** (`src/views/AvatarManage.vue`)
|
||||
- 移除“我的会会资料”区块,只保留数字分身相关内容。
|
||||
- 将只显示 `avatars[0]` 单卡片改为**全部分身列表**。
|
||||
- 每个分身卡片增加“编辑”和“删除”按钮。
|
||||
- 新增删除二次确认弹窗(明确说明会级联清除知识库、问答对、授权)。
|
||||
- 空状态增加“立即创建”入口。
|
||||
|
||||
2. **删除功能闭环** (`src/store/avatar.ts` + `backend/routers/avatars.py`)
|
||||
- store 新增 `removeAvatar(id)`:调后端删除,同步前端列表与当前选中态。
|
||||
- 后端 `delete_avatar` 增加级联清理:`KnowledgeDoc` / `KnowledgeChunk` / `QAPair` / `Authorization`(按 `avatar_id`)。
|
||||
|
||||
3. **短信登录真实生产改造** (`backend/routers/huihui_auth.py`)
|
||||
- 生产调用为默认;`HUIHUI_DEV_MOCK` 仅作为无凭证时的开发兜底。
|
||||
- 登录响应解析增强:兼容 `data.userInfo` 嵌套、`openid`、`nickName`、`avatar` 等字段,确保能正确取出生产用户 ID、昵称、头像并同步到本地 `users` 表。
|
||||
- 缺失会会凭证或端点时返回清晰 500/502 错误,不再静默兜底。
|
||||
|
||||
4. **验证**
|
||||
- 前端 `vite build` 成功(继续使用 `./node_modules/.bin/vite build` 绕过 `vue-tsc` 在 Node 22 的已知崩溃)。
|
||||
- 后端 `python -m py_compile routers/avatars.py routers/huihui_auth.py` 通过。
|
||||
|
||||
## 设计稿
|
||||
|
||||
- Ardot 画布(重设计稿):https://ardot.tencent.com/file/704821918691080
|
||||
|
||||
## 仍阻塞:真实会会短信登录
|
||||
|
||||
真实会会短信发送端点 + 开放平台凭证仍未知。此前尝试的默认路径 `/open/login/sms/send` 对生产 `https://99hui.com/api/usercenter` 返回 404;`/open/login/token` 可达,但短信发送端点路径与会会 app 凭证(appId / accessId / accessSecret / clientCode)需要用户提供或查阅会会开放平台文档。
|
||||
|
||||
## 下一步
|
||||
|
||||
用户提供后会立即完成:
|
||||
- 真实 SMS 发送端点路径(替换 `HUIHUI_SMS_SEND_PATH`)
|
||||
- `HUIHUI_APP_ID`、`HUIHUI_ACCESS_ID`、`HUIHUI_ACCESS_SECRET`、`HUIHUI_CLIENT_CODE`
|
||||
- 关闭 `HUIHUI_DEV_MOCK`(或置空)
|
||||
- 重新部署后端,用真实手机号走全链路实测。
|
||||
128
scripts/batch_replace_avatars.py
Normal file
128
scripts/batch_replace_avatars.py
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
API_BASE = os.getenv("HUIHUI_API_BASE", "http://192.168.1.188:8000/api").rstrip("/")
|
||||
AVATAR_DIR = Path(os.getenv("HUIHUI_AVATAR_DIR", str(Path.home() / "Downloads" / "头像")))
|
||||
ACCOUNT_START = os.getenv("HUIHUI_ACCOUNT_START", "13721560041")
|
||||
ACCOUNT_END = os.getenv("HUIHUI_ACCOUNT_END", "13721560199")
|
||||
POLL_SECONDS = int(os.getenv("HUIHUI_POLL_SECONDS", "10"))
|
||||
TIMEOUT_SECONDS = int(os.getenv("HUIHUI_TIMEOUT_SECONDS", "5400"))
|
||||
|
||||
|
||||
def load_avatar_files(expected_count: int) -> list[Path]:
|
||||
files = sorted(path for path in AVATAR_DIR.iterdir() if path.is_file())
|
||||
if len(files) < expected_count:
|
||||
raise RuntimeError(f"头像文件不足: 需要 {expected_count} 个,实际只有 {len(files)} 个")
|
||||
return files[:expected_count]
|
||||
|
||||
|
||||
def fetch_target_users(client: httpx.Client) -> list[dict]:
|
||||
users: list[dict] = []
|
||||
page = 1
|
||||
page_size = 100
|
||||
|
||||
while True:
|
||||
resp = client.get(
|
||||
f"{API_BASE}/users",
|
||||
params={"page": page, "page_size": page_size, "keyword": "1372156"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()["data"]
|
||||
items = payload["items"]
|
||||
users.extend(items)
|
||||
if page * page_size >= payload["total"] or not items:
|
||||
break
|
||||
page += 1
|
||||
|
||||
targets = [u for u in users if ACCOUNT_START <= u["account"] <= ACCOUNT_END]
|
||||
targets.sort(key=lambda item: item["account"])
|
||||
return targets
|
||||
|
||||
|
||||
def upload_avatar(client: httpx.Client, user: dict, avatar_path: Path) -> str:
|
||||
mime = mimetypes.guess_type(str(avatar_path))[0] or "application/octet-stream"
|
||||
with avatar_path.open("rb") as fp:
|
||||
resp = client.post(
|
||||
f"{API_BASE}/users/{user['id']}/upload-avatar",
|
||||
params={"sync_to_platform": "true"},
|
||||
files={"file": (avatar_path.name, fp, mime)},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if payload.get("code") not in (0, 200):
|
||||
raise RuntimeError(payload.get("message") or f"上传失败: {payload}")
|
||||
return payload["data"]["avatar_url"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
started_at = time.time()
|
||||
processed: set[str] = set()
|
||||
|
||||
with httpx.Client(timeout=30) as client:
|
||||
initial_targets = fetch_target_users(client)
|
||||
if not initial_targets:
|
||||
raise RuntimeError("没有找到目标手机号范围内的用户")
|
||||
|
||||
avatar_files = load_avatar_files(len(initial_targets))
|
||||
avatar_map = {
|
||||
user["account"]: avatar_files[idx]
|
||||
for idx, user in enumerate(initial_targets)
|
||||
}
|
||||
|
||||
print(
|
||||
f"准备处理 {len(initial_targets)} 个用户,头像目录 {AVATAR_DIR},接口 {API_BASE}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
while len(processed) < len(initial_targets):
|
||||
if time.time() - started_at > TIMEOUT_SECONDS:
|
||||
pending = sorted(set(avatar_map) - processed)
|
||||
print(f"超时,仍有 {len(pending)} 个用户未完成: {', '.join(pending[:20])}", flush=True)
|
||||
return 2
|
||||
|
||||
targets = fetch_target_users(client)
|
||||
status_summary: dict[int, int] = {}
|
||||
for user in targets:
|
||||
status_summary[user["status"]] = status_summary.get(user["status"], 0) + 1
|
||||
|
||||
for user in targets:
|
||||
account = user["account"]
|
||||
if account in processed:
|
||||
continue
|
||||
if user["status"] != 2:
|
||||
continue
|
||||
|
||||
avatar_url = upload_avatar(client, user, avatar_map[account])
|
||||
processed.add(account)
|
||||
print(
|
||||
f"[{len(processed):03d}/{len(targets)}] {account} -> {avatar_map[account].name} -> {avatar_url}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if len(processed) < len(initial_targets):
|
||||
print(
|
||||
f"等待登录中: 已完成 {len(processed)}/{len(initial_targets)},状态分布 {status_summary}",
|
||||
flush=True,
|
||||
)
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
print("全部头像替换完成", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("已中断", file=sys.stderr)
|
||||
raise SystemExit(130)
|
||||
73
scripts/repair_segment_nicknames.py
Normal file
73
scripts/repair_segment_nicknames.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repair nickname fields for the imported phone segment.
|
||||
|
||||
This fixes users whose nickname was overwritten back to the phone number by
|
||||
setting nickname = real_name for the target account range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pymysql
|
||||
|
||||
|
||||
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
|
||||
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
||||
DB_USER = os.getenv("DB_USER", "aivirtual")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "AiVirtual2024")
|
||||
DB_NAME = os.getenv("DB_NAME", "ai_virtual_news")
|
||||
|
||||
ACCOUNT_START = os.getenv("ACCOUNT_START", "13721560041")
|
||||
ACCOUNT_END = os.getenv("ACCOUNT_END", "13721560199")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
conn = pymysql.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
database=DB_NAME,
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, account, nickname, real_name
|
||||
FROM virtual_users
|
||||
WHERE account BETWEEN %s AND %s
|
||||
ORDER BY account
|
||||
""",
|
||||
(ACCOUNT_START, ACCOUNT_END),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
print("No matching users found.")
|
||||
return 1
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE virtual_users
|
||||
SET nickname = real_name
|
||||
WHERE account BETWEEN %s AND %s
|
||||
AND real_name IS NOT NULL
|
||||
AND real_name <> ''
|
||||
AND nickname <> real_name
|
||||
""",
|
||||
(ACCOUNT_START, ACCOUNT_END),
|
||||
)
|
||||
updated = cur.rowcount
|
||||
conn.commit()
|
||||
|
||||
print(f"Checked {len(rows)} users, repaired {updated} nicknames.")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
126
uniapp-avatar/README.md
Normal file
126
uniapp-avatar/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 会会数字分身 · uniapp + H5 混合架构
|
||||
|
||||
> 移动端采用 **uniapp 原生壳 + web-view 内嵌现有 H5** 的混合模式,与会会主 App 一致。
|
||||
> 业务页面(数字分身管理 / 知识库 / 对话等)继续用 `digital-avatar-app`(Vue3 SPA)开发并构建为 H5,
|
||||
> 由本工程的 uniapp 壳通过 `<web-view>` 加载,原生侧只负责登录、会会资料、导航壳。
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 会会 App(uniapp 原生壳) uniapp-avatar/ │
|
||||
│ ├─ 会会统一登录(token / userId / 资料) │
|
||||
│ ├─ 原生导航壳(自定义导航栏 / 可选 tabBar) │
|
||||
│ └─ <web-view :src="H5?token=...&userId=..."> │
|
||||
└───────────────────────┬─────────────────────┘
|
||||
│ web-view
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 会会数字分身 H5 digital-avatar-app/ │
|
||||
│ ├─ Vue3 + Vite SPA(构建产物即 H5) │
|
||||
│ ├─ 从 URL / window.__uniBridgeHandle__ 取认证 │
|
||||
│ └─ 通过 uni.webView.postMessage 回传原生事件 │
|
||||
└─────────────────────────────────────────────┘
|
||||
│ HTTPS /api
|
||||
▼
|
||||
FastAPI 后端(:8000)
|
||||
```
|
||||
|
||||
**职责边界**
|
||||
| 层 | 负责 |
|
||||
|----|------|
|
||||
| uniapp 壳 | 会会登录与会话、注入 token/用户、原生导航与返回、可承载原生能力(推送/分享/支付) |
|
||||
| H5 业务 | 全部数字分身业务 UI 与交互、调用后端 API、知识库/问答/对话 |
|
||||
| 后端 | 业务数据与 AI 能力(维持不变) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 桥接协议(壳 ↔ H5)
|
||||
|
||||
### 2.1 握手(URL 注入,最可靠)
|
||||
壳加载 web-view 时把认证与会会资料拼进 H5 地址的 query:
|
||||
```
|
||||
H5_BASE_URL/?token=<accessToken>&userId=<uid>&nickname=<urlencode>&avatar=<urlencode>&ts=<ms>
|
||||
```
|
||||
H5 在 `main.ts` 启动前用 `getLaunchParams()` 解析;`nickname` / `avatar` 需 `encodeURIComponent`。
|
||||
|
||||
### 2.2 H5 → 原生(`uni.webView.postMessage`)
|
||||
H5 引入 uniapp web-view bridge 后调用:
|
||||
| type | payload | 含义 |
|
||||
|------|---------|------|
|
||||
| `ready` | — | H5 已完成首屏渲染 |
|
||||
| `needLogin` | — | token 失效,请求壳重新登录 |
|
||||
| `setTitle` | `title` | 设置原生导航栏标题 |
|
||||
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
|
||||
| `back` | — | 请求原生返回 |
|
||||
|
||||
### 2.3 原生 → H5(壳主动推送)
|
||||
壳通过 `web-view.evalJS` 调用 H5 全局函数 `window.__uniBridgeHandle__(message)`:
|
||||
| type | payload | 含义 |
|
||||
|------|---------|------|
|
||||
| `context` | `platform, version` | 注入运行环境信息 |
|
||||
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
|
||||
| `userUpdate` | `user` | 会会资料变更 |
|
||||
|
||||
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构(uni CLI / src 布局,已验证可编译)
|
||||
|
||||
```
|
||||
uniapp-avatar/
|
||||
├── index.html # H5 入口模板(项目根,uni h5 在此找入口)
|
||||
├── vite.config.js # 注册 @dcloudio/vite-plugin-uni(.vue 编译必须)
|
||||
├── package.json
|
||||
├── README.md
|
||||
└── src/ # 源码根(uni CLI 要求 manifest/pages 在此)
|
||||
├── manifest.json # 应用配置(名称/AppID/模块)
|
||||
├── pages.json # 页面路由
|
||||
├── uni.scss # 全局样式变量
|
||||
├── App.vue # 启动即做会会登录(onLaunch → userStore.init)
|
||||
├── main.js # createSSRApp + pinia
|
||||
├── pages/index/index.vue # web-view 容器(内嵌 digital-avatar-app H5)
|
||||
├── store/user.js # 会会会话(token/资料,本地缓存)
|
||||
└── utils/
|
||||
├── bridge.js # H5 URL 构造 + 原生→H5 推送
|
||||
└── huihui.js # 会会登录(MOCK,留真实接入位)
|
||||
```
|
||||
> 构建产物:`npm run build:h5` → `dist/build/h5/`(含 index.html + assets)。
|
||||
|
||||
## 4. 运行与打包
|
||||
|
||||
### 方式一:HBuilderX(推荐,与生产一致)
|
||||
1. HBuilderX → 导入 `uniapp-avatar/` 目录(已含 `manifest.json`/`pages.json`)。
|
||||
2. 顶部菜单「运行」→ 运行到手机/模拟器/浏览器(H5)。
|
||||
3. 修改 `src/pages/index/index.vue` 的 `H5_BASE_URL` 指向你的 H5 部署地址:
|
||||
- 本地联调用的**局域网 IP + Vite 端口**(如 `http://192.168.1.100:5173`),不要用 `localhost`。
|
||||
- 生产填 digital-avatar-app 构建后的 H5 线上域名。
|
||||
4. 打包:发行 → 原生 App(云打包/本地打包)。
|
||||
|
||||
### 方式二:CLI(dev:h5 本地自测,已验证通过)
|
||||
```bash
|
||||
cd uniapp-avatar
|
||||
npm install # 依赖版本已对齐本机 HBuilderX(3.0.0-4010520240507001)
|
||||
npm run dev:h5 # 起 H5 版壳,web-view 会加载 H5_BASE_URL 指向的 H5
|
||||
npm run build:h5 # 生产构建 → dist/build/h5/
|
||||
```
|
||||
> 若 npm 依赖版本与本地 HBuilderX 不一致,执行 `npx @dcloudio/uvm` 对齐。
|
||||
|
||||
### 会会登录接入
|
||||
- 当前 `utils/huihui.js` 为 **MOCK**(`MOCK_AUTH = true`),便于联调。
|
||||
- 生产接入:把 `MOCK_AUTH` 改为 `false`,在 `loginHuihui()` 接入会会开放平台授权,
|
||||
换取 `access_token`、`userId`;`getUserInfo()` 请求会会 `usercenter` 真实资料接口
|
||||
(接口基址见 `docs/production-interface-inventory.md`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. H5 侧改动清单(digital-avatar-app)
|
||||
- 新增 `src/utils/uniapp-bridge.ts`:环境探测、参数解析、双向消息。
|
||||
- `src/api/index.ts`:`baseURL` 改为可配置(支持 web-view 内绝对地址)。
|
||||
- `src/store/avatar.ts`:优先采用壳传入的会会资料;新增 `setNativeProfile`。
|
||||
- `src/main.ts`:挂载前注入 token/用户,注册原生消息处理,渲染后发 `ready`。
|
||||
- `src/router/index.ts`:改用 `createWebHashHistory()`(web-view 下返回键更稳)。
|
||||
- `index.html`:引入 uniapp web-view bridge 脚本,注入 `apiBase` 配置。
|
||||
17
uniapp-avatar/index.html
Normal file
17
uniapp-avatar/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(safe-area-inset-top)') || CSS.supports('top: constant(safe-area-inset-top)'))
|
||||
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title>会会数字分身</title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user