feat: complete huihui square avatar workflows
This commit is contained in:
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 代码,未在画布上直接设计。
|
||||
Reference in New Issue
Block a user