Compare commits

...

40 Commits

Author SHA1 Message Date
894eeb8c4b perf(home): 拆分 ChatComposer 修复输入框打字卡顿
All checks were successful
CI / build (push) Successful in 3m7s
将输入草稿状态从 HomePage 抽离为独立 memo 组件,避免每次按键重渲染消息列表与侧栏;并微调输入区与滚动条样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 17:08:24 +08:00
0095291eb0 feat(legal): 新增静态协议页并完善登录页合规入口
All checks were successful
CI / build (push) Successful in 2m18s
新增用户协议与隐私政策纯静态 HTML 页面,优化文档样式并补充合规占位信息。同时更新登录页备案号与协议链接,并移除 tsconfig 中不兼容的弃用配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 11:57:56 +08:00
8018438f42 feat(ui): 统一登录与会话页的 shadcn 交互组件
All checks were successful
CI / build (push) Successful in 2m31s
登录页切换为官方 input-group 组合并将全站提示从 antd message 统一为 sonner toast,确保通知位置与风格一致。同时补齐相关 shadcn CLI 生成组件及构建配置更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 18:33:57 +08:00
2f901c5488 ui: 优化首页logo标题样式;
Some checks failed
CI / build (push) Successful in 1m55s
Deploy To Server / deploy (push) Failing after 2s
2026-04-26 00:38:36 +08:00
20aeb94f6e chore(branding): 更新登录与首页标题资源及部署配置
Some checks failed
CI / build (push) Successful in 2m18s
Deploy To Server / deploy (push) Failing after 2s
引入新的 logo-title 资源并替换登录页与首页左上角标题展示,统一相关样式细节;同时补充 Docker 与 Gitea 部署配置,便于服务器端自动化构建发布。

Made-with: Cursor
2026-04-25 23:19:52 +08:00
6e8acef10f chore(cache): 切换为 Nginx 静态资源缓存策略
All checks were successful
CI / build (push) Successful in 2m6s
将图标资源迁移到 src/assets 并通过构建产物 hash 管理缓存,移除 public 目录旧图标引用;新增 Nginx 配置,设置 index.html 协商缓存与 /assets 长缓存策略。

Made-with: Cursor
2026-04-24 16:14:56 +08:00
d93b4d178f chore(assets): 更新并压缩站点图标资源
All checks were successful
CI / build (push) Successful in 4m9s
替换站点 logo、favicon 与 apple touch 图标为新版本,并在保留透明效果的同时压缩到更合适的尺寸与体积,减少静态资源开销。

Made-with: Cursor
2026-04-24 14:55:33 +08:00
891f09aa0d feat(chat): 消息列表回到底部与贴底滚动优化
All checks were successful
CI / build (push) Successful in 2m7s
- 非底部时显示悬浮回到底部按钮,点击平滑滚底并隐藏
- 仅在已处于底部附近时随新消息自动贴底,避免打断上翻阅读
- 按钮使用原生圆钮并相对输入区 max-w-4xl 定位,避免 AntD 按钮宽条问题
- 同步 lucide-react 依赖及流式代码块、全局样式等小改动

Made-with: Cursor
2026-04-24 04:39:24 +08:00
3b0b6eac50 feat: 完善会话交互并统一聊天页视觉样式
All checks were successful
CI / build (push) Successful in 10m45s
补齐会话重命名/删除与会话菜单能力,新增聊天参数透传与本地开关持久化,并统一输入区、消息区、代码块和弹框等关键交互样式。

Made-with: Cursor
2026-04-23 23:09:23 +08:00
b3fdb0ad4b feat: 接入会话列表并支持路由记忆当前会话
All checks were successful
CI / build (push) Successful in 1m59s
新增会话与消息查询 API,并将首页改为真实会话驱动;当前选中会话会同步到 URL 参数,刷新或直达链接可恢复上下文。

Made-with: Cursor
2026-04-22 23:32:07 +08:00
9a1b59663b refactor(auth): 按扁平接口简化鉴权解析
All checks were successful
CI / build (push) Successful in 3m13s
登录/刷新/发短信直接解析根级字段,移除 data 包装与 mergeUser 等多余逻辑。
首页侧栏用户信息随 localStorage 与 chatone-user-changed 更新,优先展示 nickname。

Made-with: Cursor
2026-04-22 01:25:34 +08:00
d4a91f11cb feat: 短信登录、会话续签与侧栏体验优化
All checks were successful
CI / build (push) Successful in 2m17s
新增 auth/paths 与登录页;HTTP 与流式聊天在 401 时续签,失败则清理会话并跳转登录。
侧栏用户信息区布局修复;用户菜单项间距与验证码发送冷却、antd 发送按钮。

Made-with: Cursor
2026-04-21 06:30:43 +08:00
6579578a62 ui: 支持模型选择并透传到聊天接口;
All checks were successful
CI / build (push) Successful in 2m39s
Made-with: Cursor
2026-04-17 02:25:45 +08:00
056c3e648f ui: 抽离流式消息渲染组件并增强富文本显示;
All checks were successful
CI / build (push) Successful in 2m12s
将助手消息渲染从页面内联逻辑拆分为独立组件,接入 Markdown/GFM、代码高亮与公式渲染,提升流式输出在表格、代码块等主流格式下的展示能力。

Made-with: Cursor
2026-04-16 02:11:54 +08:00
038dc5e918 ui: 接入千问流式聊天并增强请求稳定性;
All checks were successful
CI / build (push) Successful in 1m52s
将首页从 mock 数据切换为真实流式会话,统一 API 网络层并支持 SSE 增量解析、超时与断线重试,提升前端在跨域代理和网络抖动场景下的可用性。

Made-with: Cursor
2026-04-15 23:03:54 +08:00
f46b50fc1e ui: 优化样式;
All checks were successful
CI / build (push) Successful in 2m17s
2026-04-13 14:18:33 +08:00
70e1aa439f ci: 更新公共ci脚本版本;新增ci参数;
All checks were successful
CI / build (push) Successful in 1m53s
2026-04-13 03:47:06 +08:00
b1fb19f404 ui: 新增logo;
All checks were successful
CI / build (push) Successful in 2m19s
2026-04-13 00:50:51 +08:00
d179cd53be ci: 调用 platform/workflow 公共工作流并精简注释
All checks were successful
CI / build (push) Successful in 2m17s
ui: 首页对齐 DeepSeek 式布局(侧栏、消息气泡、思考折叠、输入区胶囊与发送按钮)
Made-with: Cursor
2026-04-12 21:24:12 +08:00
7cad29aac2 ci: 修改公共ci脚本目录;
All checks were successful
CI / build (push) Successful in 1m50s
2026-04-12 01:39:51 +08:00
017919f6bc ci: ci使用公共工作流;
Some checks failed
CI / build (push) Failing after 0s
2026-04-12 01:35:32 +08:00
f2a23308b0 feat: 修改聊天按钮;
All checks were successful
CI / build (push) Successful in 1m43s
2026-04-11 03:03:27 +08:00
2327e3a3b1 ci: 优化ci配置;
All checks were successful
CI / build (push) Successful in 1m46s
2026-04-11 02:54:26 +08:00
6976c0c2eb ci: 优化此配置;
Some checks failed
CI / build (push) Failing after 32s
2026-04-11 02:44:44 +08:00
691d68c59a ci: 优化ci配置;
Some checks failed
CI / build (push) Failing after 2m22s
2026-04-10 23:10:54 +08:00
7917975b5d ci: 优化此配置;
Some checks failed
CI / build (push) Failing after 2m23s
2026-04-10 23:04:20 +08:00
001af10c2a ci: 修改ci配置,新增部署配置;
Some checks failed
CI / build (push) Has been cancelled
2026-04-10 22:54:33 +08:00
080881bda1 ci: 优化此配置;
All checks were successful
CI / build (push) Successful in 2m25s
2026-04-10 01:14:08 +08:00
ac3fd70fef ci: 优化ci配置
All checks were successful
CI / build (push) Successful in 2m55s
2026-04-05 18:53:41 +08:00
ba188ff2d8 ci: 优化ci配置
All checks were successful
CI / build (push) Successful in 2m54s
2026-04-05 18:44:01 +08:00
948a022b77 ci: 优化ci配置
All checks were successful
CI / build (push) Successful in 2m54s
2026-04-05 18:40:16 +08:00
26de9ad9b8 style: 优化代码格式
All checks were successful
CI / build (push) Successful in 2m54s
2026-04-05 18:20:47 +08:00
ae2d6419fe ci: 修改ci配置
Some checks failed
CI / build (push) Failing after 3m38s
2026-04-05 17:52:24 +08:00
5fe57fd087 ci: 修改ci配置
Some checks failed
CI / build (push) Has been cancelled
2026-04-05 17:38:36 +08:00
693835a37e ci: 修改ci配置
Some checks failed
CI / build (push) Has been cancelled
2026-04-05 17:21:21 +08:00
9d1ec850ed ci: 优化ci配置
Some checks failed
CI / build (push) Has been cancelled
2026-04-05 17:15:27 +08:00
b1cfd4f04c ci: 新增ci配置文件
Some checks failed
CI / build (push) Failing after 3m39s
2026-04-05 16:45:51 +08:00
c490bd6f76 style: 测试; 2026-04-03 21:18:27 +08:00
cd143d9541 style: 测试 2026-04-03 21:17:42 +08:00
fa801fe273 style: 测试代码校验 2026-04-03 21:17:04 +08:00
42 changed files with 14603 additions and 3699 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitignore
node_modules
dist
.idea
.vscode
.cursor
*.log
Dockerfile*
docker-compose*.yml

24
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,24 @@
# 业务 CI调用 platform/workflow 可复用工作流(固定 tag 1.0,升级时改 @ 后缀并同步公共仓打 tag
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
uses: platform/workflow/.gitea/workflows/web-spa-deploy.yml@1.0
with:
node_version: "22.14.0"
yarn_version: "1.22.22"
project_dir: "chat-one-web"
build_script: "yarn build"
build_output_dir: "dist"
secrets: inherit

View File

@@ -0,0 +1,48 @@
name: Deploy To Server
on:
# 暂停自动/手动部署触发;需要恢复时改回 push / workflow_dispatch。
workflow_dispatch:
inputs:
disabled:
description: "Deployment is temporarily disabled"
required: false
default: "true"
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
if: ${{ false }}
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
DEPLOY_SSH_KNOWN_HOSTS: ${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
DEPLOY_WORKDIR: ${{ secrets.DEPLOY_WORKDIR }}
run: |
set -eu
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
SSH_PORT="${DEPLOY_PORT:-22}"
REMOTE_DIR="${DEPLOY_WORKDIR:-/opt/chat-one-web}"
ssh -p "$SSH_PORT" "$DEPLOY_USER@$DEPLOY_HOST" <<EOF
set -eu
cd "$REMOTE_DIR"
git pull --ff-only
docker compose up -d --build
docker image prune -f
EOF

940
.yarn/releases/yarn-4.14.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

1
.yarnrc Normal file
View File

@@ -0,0 +1 @@
registry "https://registry.npmmirror.com"

8
.yarnrc.yml Normal file
View File

@@ -0,0 +1,8 @@
approvedGitRepositories:
- "**"
enableScripts: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.14.1.cjs

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
# 先复制依赖清单,利用 Docker layer cache
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# 再复制源码并构建
COPY . .
RUN yarn build
FROM nginx:1.27-alpine AS runner
WORKDIR /usr/share/nginx/html
# 使用官方 Nginx 模板能力,在容器启动时注入 API_UPSTREAM
COPY nginx.conf /etc/nginx/templates/default.conf.template
# 仅拷贝构建产物,减小镜像体积
COPY --from=builder /app/dist ./
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
version: "3.9"
services:
chat-one-web:
build:
context: .
dockerfile: Dockerfile
image: chat-one-web:latest
container_name: chat-one-web
restart: unless-stopped
environment:
# 后端接口地址;如果后端也在 compose 网络中,改成类似 http://chat-one-api:3000
API_UPSTREAM: http://host.docker.internal:3000
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "80:80"

View File

@@ -2,7 +2,12 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="initial-scale=1.0,maximum-scale=1,minimum-scale=1.0,user-scalable=no,width=device-width,viewport-fit=cover"
/>
<link rel="icon" type="image/png" sizes="32x32" href="/src/assets/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/src/assets/apple-touch-icon.png" />
<title>Chat One Web</title>
</head>
<body>

50
nginx.conf Normal file
View File

@@ -0,0 +1,50 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 后端接口反向代理Docker 启动时由 API_UPSTREAM 注入例如http://host.docker.internal:3000
location /api/ {
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
}
# Vite build 产物:文件名带 hash可长期缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# 单独图标按中等缓存(如果后续也迁到 /assets可删除
location ~* ^/(favicon\.ico|favicon\.png|apple-touch-icon\.png)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
}
# 入口 HTML 每次协商缓存,确保能拿到最新资源清单
location = /index.html {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# SPA 路由回退
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -25,10 +25,29 @@
"*.{css,json,md,html,yml,yaml,js,mjs,cjs}": "prettier --write"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@microsoft/fetch-event-source": "^2.0.1",
"antd": "^6.3.5",
"axios": "^1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"katex": "^0.16.45",
"lucide-react": "^1.14.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2"
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.2",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -50,5 +69,6 @@
"typescript-eslint": "^8.58.0",
"vite": "^8.0.3",
"vite-plugin-pages": "^0.33.3"
}
},
"packageManager": "yarn@4.14.1"
}

148
public/privacy-policy.html Normal file
View File

@@ -0,0 +1,148 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>隐私政策 - ChatOne</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: #1f2329;
background: #f6f8fb;
line-height: 1.85;
}
.container {
max-width: 920px;
margin: 0 auto;
padding: 28px 16px 40px;
}
.card {
background: #fff;
border: 1px solid #e8edf3;
border-radius: 16px;
box-shadow: 0 6px 20px rgba(31, 35, 41, 0.06);
padding: 24px 20px;
}
.hero {
margin-bottom: 28px;
}
h1 {
margin: 6px 0 10px;
font-size: 28px;
line-height: 1.35;
}
.title-center {
text-align: center;
}
h2 {
margin: 28px 0 8px;
font-size: 19px;
line-height: 1.45;
}
p {
margin: 8px 0;
}
ul {
margin: 8px 0;
padding-left: 22px;
}
li {
margin: 2px 0;
}
.muted {
color: #667085;
font-size: 14px;
}
@media (max-width: 640px) {
.container {
padding: 16px 12px 24px;
}
.card {
border-radius: 12px;
padding: 18px 14px;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 17px;
margin-top: 22px;
}
}
</style>
</head>
<body>
<main class="container">
<article class="card">
<header class="hero">
<h1 class="title-center">ChatOne隐私政策</h1>
<p class="muted">版本号:【待补充,例如 v1.0.0】</p>
<p class="muted">更新日期:【待补充】</p>
<p class="muted">生效日期:【待补充】</p>
</header>
<h2>0. 运营者信息</h2>
<p>运营主体:【待补充公司全称】</p>
<p>统一社会信用代码:【待补充】</p>
<p>注册地址:【待补充】</p>
<p>联系邮箱:【待补充】</p>
<p>联系电话:【待补充】</p>
<h2>1. 我们如何收集和使用个人信息</h2>
<p>为提供基础功能,我们可能收集并使用以下信息:</p>
<ul>
<li>账号信息:手机号、验证码;</li>
<li>使用信息:会话内容、操作日志、设备与网络基础信息;</li>
<li>服务保障信息:故障排查、安全风控所需的信息。</li>
</ul>
<p>
请补充“个人信息处理清单”(建议至少包含:信息类型、处理目的、处理方式、保存期限、删除规则)。
</p>
<h2>2. 处理个人信息的目的与依据</h2>
<p>
我们基于提供服务、履行法定义务、维护网络与运营安全等必要目的处理你的个人信息,并严格遵守《个人信息保护法》等法律法规。
</p>
<h2>3. 我们如何共享、转让、公开披露个人信息</h2>
<p>
除法律法规另有规定或获得你的单独同意外,我们不会向第三方共享、转让或公开披露你的个人信息。确需共享时,我们将说明共享目的、方式和范围,并要求接收方采取保护措施。
</p>
<p>请补充第三方清单:【待补充:第三方名称、共享字段、用途、隐私政策链接】。</p>
<h2>4. 信息存储与保护</h2>
<p>
我们采取合理的技术与管理措施保护个人信息安全,并在达到处理目的所必需的最短期限内保存你的个人信息。超出保存期限后将依法删除或匿名化处理。
</p>
<p>
如存在跨境传输,请补充:【待补充:接收方、国家/地区、类型、保护措施】;如无,请明确“我们不进行个人信息跨境传输”。
</p>
<h2>5. 你的权利</h2>
<p>
你可依法行使查阅、复制、更正、删除、撤回同意、注销账号等权利。你可通过本政策“联系我们”渠道提交请求。
</p>
<h2>6. 未成年人保护</h2>
<p>
若你是未满 14
周岁的未成年人,请在监护人同意与指导下使用本服务。我们将按照法律规定对未成年人个人信息采取特别保护措施。
</p>
<h2>7. 政策更新</h2>
<p>
我们可能适时更新本政策。若发生重大变更,我们将通过适当方式提示。变更后的政策公布后生效。
</p>
<h2>8. 联系我们</h2>
<p>如对本政策有疑问或需行使个人信息权利,请联系: 【待补充:邮箱 / 电话 / 通信地址】。</p>
</article>
</main>
</body>
</html>

148
public/user-agreement.html Normal file
View File

@@ -0,0 +1,148 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用户协议 - ChatOne</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: #1f2329;
background: #f6f8fb;
line-height: 1.85;
}
.container {
max-width: 920px;
margin: 0 auto;
padding: 28px 16px 40px;
}
.card {
background: #fff;
border: 1px solid #e8edf3;
border-radius: 16px;
box-shadow: 0 6px 20px rgba(31, 35, 41, 0.06);
padding: 24px 20px;
}
.hero {
margin-bottom: 28px;
}
h1 {
margin: 6px 0 10px;
font-size: 28px;
line-height: 1.35;
}
.title-center {
text-align: center;
}
h2 {
margin: 28px 0 8px;
font-size: 19px;
line-height: 1.45;
}
p {
margin: 8px 0;
}
ul {
margin: 8px 0;
padding-left: 22px;
}
li {
margin: 2px 0;
}
.muted {
color: #667085;
font-size: 14px;
}
@media (max-width: 640px) {
.container {
padding: 16px 12px 24px;
}
.card {
border-radius: 12px;
padding: 18px 14px;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 17px;
margin-top: 22px;
}
}
</style>
</head>
<body>
<main class="container">
<article class="card">
<header class="hero">
<h1 class="title-center">ChatOne用户协议</h1>
<p class="muted">版本号:【待补充,例如 v1.0.0】</p>
<p class="muted">更新日期:【待补充】</p>
<p class="muted">生效日期:【待补充】</p>
</header>
<h2>0. 运营者信息</h2>
<p>运营主体:【待补充公司全称】</p>
<p>统一社会信用代码:【待补充】</p>
<p>注册地址:【待补充】</p>
<p>联系邮箱:【待补充】</p>
<p>联系电话:【待补充】</p>
<h2>1. 协议范围</h2>
<p>
欢迎使用
ChatOne以下简称“本服务”。本协议是你与本服务运营方之间就使用本服务所订立的协议。你在注册、登录、使用本服务前应当仔细阅读并充分理解本协议全部条款。
</p>
<h2>2. 账号与安全</h2>
<p>
你应当使用合法、真实、有效的信息注册和使用账号,并妥善保管账号及验证码。因你保管不善导致的风险和损失由你自行承担。
</p>
<h2>3. 服务使用规范</h2>
<p>
你承诺不得利用本服务从事违反法律法规、公序良俗或侵害他人合法权益的行为,包括但不限于:
</p>
<ul>
<li>发布、传播违法违规信息;</li>
<li>侵害他人知识产权、名誉权、隐私权等合法权益;</li>
<li>干扰、破坏本服务正常运行或实施任何危害网络安全的行为。</li>
</ul>
<h2>4. AI 生成内容说明</h2>
<p>
本服务包含基于人工智能生成的内容。相关内容仅供参考,不构成任何专业建议或承诺。你应结合自身情况独立判断并自行承担使用风险。
</p>
<h2>5. 知识产权</h2>
<p>
本服务及相关软件、页面、标识、文本、图像等内容的知识产权归运营方或权利人所有。未经许可,不得擅自复制、传播、修改或用于其他商业用途。
</p>
<h2>6. 责任限制</h2>
<p>
在法律允许范围内,运营方对因不可抗力、网络故障、第三方原因或你自身原因导致的服务中断、数据丢失或其他损失,不承担超出法定范围的责任。
</p>
<h2>7. 协议变更与终止</h2>
<p>
运营方可根据法律法规或业务需要更新本协议。更新后的协议公布后生效。若你继续使用本服务,视为接受更新后的协议。
</p>
<h2>8. 争议解决</h2>
<p>
本协议的订立、执行和解释适用中华人民共和国法律。因本协议引起的争议,双方应先协商解决;协商不成的,提交【待补充:运营方所在地城市】有管辖权的人民法院诉讼解决。
</p>
<h2>9. 联系我们</h2>
<p>如有疑问,请通过以下方式联系: 【待补充:客服邮箱 / 电话 / 通信地址】。</p>
</article>
</main>
</body>
</html>

141
src/api/auth.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* 鉴权相关:登录、刷新令牌、本地会话读写。
*
* 会话默认存 `localStorage`,键名见下方常量(与 `http.ts` 里读取的 `accessToken` 一致)。
*/
import { postJson } from "./http";
import { ApiPath } from "./paths";
/** localStorage访问令牌 */
export const ACCESS_TOKEN_KEY = "accessToken";
/** localStorage刷新令牌 */
export const REFRESH_TOKEN_KEY = "refreshToken";
/** localStorage用户信息 JSON 字符串 */
export const USER_KEY = "user";
export type AuthUser = Record<string, unknown>;
/** `ClientAuthUserDto`(与 OpenAPI 一致) */
export type ClientAuthUserDto = {
id: string;
phone: string;
nickname: string;
avatarUrl: string;
};
/** 发送短信验证码时 `scene` 取值(与后端约定一致) */
export const AUTH_SMS_SCENE_LOGIN = "login";
/** `ClientSendSmsResponseDto` */
export type SmsSendResponse = {
requestId: string;
phone: string;
scene: string;
provider: string;
expireIn: number;
testCode?: string;
};
/** `ClientLoginResponseDto` */
export type SmsLoginResponse = {
accessToken: string;
refreshToken: string;
user: ClientAuthUserDto;
};
/** `POST .../auth/refresh` 成功响应 */
export type RefreshTokenResponse = {
accessToken: string;
refreshToken: string;
};
/** 写入一对令牌,并清理历史 mock 键 `token` */
export function persistTokens(accessToken: string, refreshToken: string): void {
window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
window.localStorage.removeItem("token");
}
/** 写入用户信息(可选) */
export function persistUser(user: AuthUser | undefined): void {
if (!user) return;
window.localStorage.setItem(USER_KEY, JSON.stringify(user));
window.dispatchEvent(new CustomEvent("chatone-user-changed"));
}
/** 清除本地会话(含兼容旧键) */
export function clearSession(): void {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
window.localStorage.removeItem("token");
window.dispatchEvent(new CustomEvent("chatone-user-changed"));
}
/**
* 续签失败、双 token 均失效等场景:清会话并回到登录页(整页跳转,避免残留状态)。
*/
export class SessionExpiredError extends Error {
override readonly name = "SessionExpiredError";
constructor(message = "登录已过期,请重新登录") {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export function invalidateSessionAndGoLogin(): void {
clearSession();
const base = import.meta.env.BASE_URL || "/";
const loginPath = base === "/" ? "/login" : `${String(base).replace(/\/$/, "")}/login`;
window.location.replace(loginPath);
}
/**
* 退出登录:尽量通知后端吊销 refresh失败则忽略并清除本地会话。
*/
export async function logout(): Promise<void> {
const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
try {
await postJson<unknown>(ApiPath.authLogout, { refreshToken });
} catch {
// 无网或接口未实现时仍允许用户本地退出
}
}
clearSession();
}
/**
* 发送短信验证码(如登录前获取验证码)。
* @param phone 国际格式,如 `+86153xxxxxxxx`
* @param scene 业务场景,默认 `AUTH_SMS_SCENE_LOGIN`
* @returns `testCode` 仅部分环境返回,用于联调自动填码
*/
export async function sendAuthSmsCode(
phone: string,
scene: string = AUTH_SMS_SCENE_LOGIN,
): Promise<{ testCode?: string }> {
const raw = await postJson<SmsSendResponse>(ApiPath.authSmsSend, { phone, scene });
const testCode = raw.testCode;
if (typeof testCode === "string" && testCode.trim()) {
return { testCode: testCode.trim() };
}
return {};
}
/**
* 短信验证码登录。
* @param phone 国际格式,如 `+86153xxxxxxxx`
* @param code 短信验证码
*/
export async function smsLogin(phone: string, code: string): Promise<SmsLoginResponse> {
return postJson<SmsLoginResponse>(ApiPath.authSmsLogin, { phone, code });
}
/**
* 使用 refreshToken 换取新的访问令牌与刷新令牌。
* 该请求在 `http` 层不会附带旧的 `Authorization`。
*/
export async function refreshTokens(refreshToken: string): Promise<RefreshTokenResponse> {
return postJson<RefreshTokenResponse>(ApiPath.authRefresh, { refreshToken });
}

150
src/api/chatSessions.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* 会话与历史消息Client Chat
*
* 类型与 `http://localhost:3000/docs` 中 components schemas 对齐:
* `CreateChatSessionDto`、`ChatSessionRowDto`、`ChatSessionListResponseDto`、
* `ChatMessageRowDto`、`ChatMessageListResponseDto`。
*/
import { deleteJson, getJson, patchJson, postJson } from "./http";
import { ApiPath, chatSessionMessagesPath, chatSessionPath, chatSessionTitlePath } from "./paths";
/** `CreateChatSessionDto` */
export type CreateChatSessionBody = {
/** 会话标题,可选,最长 200 */
title?: string;
};
/** `ChatSessionRowDto` */
export type ChatSessionRowDto = {
id: string;
userId: string;
title: string;
createdAt: string;
updatedAt: string;
};
/** 侧栏与会话列表中的「一条会话」 */
export type ChatSession = ChatSessionRowDto;
/** `ChatSessionListResponseDto` */
export type ChatSessionListResponseDto = {
items: ChatSessionRowDto[];
total: number;
limit: number;
offset: number;
};
/** `ChatMessageRowDto` */
export type ChatMessageRowDto = {
id: string;
role: string;
content: string;
tokenCount: number;
provider?: string | null;
createdAt: string;
};
/** `ChatMessageListResponseDto` */
export type ChatMessageListResponseDto = {
sessionId: string;
items: ChatMessageRowDto[];
total: number;
limit: number;
offset: number;
};
export type ListSessionsQuery = {
offset?: number;
limit?: number;
};
export type ListMessagesQuery = {
offset?: number;
limit?: number;
};
/** `UpdateChatSessionTitleDto` */
export type UpdateChatSessionTitleBody = {
/** 会话标题(可传空字符串清空) */
title: string;
};
/** 与首页消息列表结构一致,便于 `setMessages` */
export type ChatTurnForUi = {
id: string;
role: "user" | "assistant";
content: string;
thinking?: string;
};
export function sessionStableId(s: ChatSessionRowDto): string {
return s.id;
}
/** 侧栏展示标题(`title` 可能为空字符串) */
export function sessionDisplayTitle(s: ChatSessionRowDto): string {
const t = s.title.trim();
if (t) return t;
return `会话 ${s.id.slice(0, 8)}`;
}
/**
* 创建会话(可选标题)。
* 响应为 `ChatSessionRowDto`OpenAPI 标注为 200
*/
export async function createChatSession(
body: CreateChatSessionBody = {},
signal?: AbortSignal,
): Promise<ChatSessionRowDto> {
return postJson<ChatSessionRowDto>(ApiPath.chatSessions, body, signal);
}
/** 分页拉取会话列表 */
export async function listChatSessions(
query?: ListSessionsQuery,
signal?: AbortSignal,
): Promise<ChatSessionRowDto[]> {
const data = await getJson<ChatSessionListResponseDto>(ApiPath.chatSessions, query, signal);
return data.items;
}
/** 分页拉取某会话下的消息 */
export async function listChatSessionMessages(
sessionId: string,
query?: ListMessagesQuery,
signal?: AbortSignal,
): Promise<ChatMessageRowDto[]> {
const path = chatSessionMessagesPath(sessionId);
const data = await getJson<ChatMessageListResponseDto>(path, query, signal);
return data.items;
}
/** 修改会话标题 */
export async function updateChatSessionTitle(
sessionId: string,
body: UpdateChatSessionTitleBody,
signal?: AbortSignal,
): Promise<ChatSessionRowDto> {
const path = chatSessionTitlePath(sessionId);
return patchJson<ChatSessionRowDto>(path, body, signal);
}
/** 删除会话(级联删除消息) */
export async function deleteChatSession(sessionId: string, signal?: AbortSignal): Promise<void> {
const path = chatSessionPath(sessionId);
await deleteJson<void>(path, signal);
}
/** 将接口消息行规范为前端聊天列表项(忽略 `system` 等角色) */
export function normalizeSessionMessages(rows: ChatMessageRowDto[]): ChatTurnForUi[] {
const out: ChatTurnForUi[] = [];
for (const row of rows) {
if (row.role !== "user" && row.role !== "assistant") continue;
out.push({
id: row.id,
role: row.role,
content: row.content,
});
}
return out;
}

213
src/api/http.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* HTTP 客户端封装axios
*
* - `baseURL`:开发环境通常为空,走 Vite 同源代理;生产可配 `VITE_API_BASE_URL`。
* - 请求拦截:除白名单接口外,自动附加 `Authorization: Bearer <accessToken>`。
* - 错误:`postJson` 将 axios 错误统一转为 `Error`,便于页面层 `try/catch`。
*/
import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios";
import {
REFRESH_TOKEN_KEY,
SessionExpiredError,
invalidateSessionAndGoLogin,
persistTokens,
refreshTokens,
} from "./auth";
import { ApiPath, pathsWithoutBearerAuth } from "./paths";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
type JsonBody = Record<string, unknown>;
export const httpClient = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
});
httpClient.interceptors.request.use((config) => {
const path = config.url ?? "";
const skipBearer = pathsWithoutBearerAuth.some((p) => path === p || path.endsWith(p));
if (skipBearer) {
config.headers.delete("Authorization");
return config;
}
const accessToken = window.localStorage.getItem("accessToken");
if (accessToken) {
config.headers.set("Authorization", `Bearer ${accessToken}`);
}
return config;
});
type SessionRetryConfig = InternalAxiosRequestConfig & { _sessionRetried?: boolean };
/** 同一时刻共用一个 refresh避免并发 401 打爆续签接口 */
let refreshOnce: Promise<void> | null = null;
function isAuthRefreshUrl(url: string): boolean {
return url === ApiPath.authRefresh || url.endsWith(ApiPath.authRefresh);
}
httpClient.interceptors.response.use(
(response) => response,
async (error) => {
if (!axios.isAxiosError(error) || !error.config) {
return Promise.reject(error);
}
const status = error.response?.status;
const config = error.config as SessionRetryConfig;
if (status !== 401) {
return Promise.reject(error);
}
const path = config.url ?? "";
if (isAuthRefreshUrl(path)) {
invalidateSessionAndGoLogin();
return Promise.reject(new SessionExpiredError());
}
if (pathsWithoutBearerAuth.some((p) => path === p || path.endsWith(p))) {
return Promise.reject(error);
}
if (config._sessionRetried) {
invalidateSessionAndGoLogin();
return Promise.reject(new SessionExpiredError());
}
config._sessionRetried = true;
if (!refreshOnce) {
refreshOnce = (async () => {
const rt = window.localStorage.getItem(REFRESH_TOKEN_KEY);
if (!rt) {
invalidateSessionAndGoLogin();
throw new SessionExpiredError();
}
try {
const data = await refreshTokens(rt);
persistTokens(data.accessToken, data.refreshToken);
} catch {
invalidateSessionAndGoLogin();
throw new SessionExpiredError();
}
})().finally(() => {
refreshOnce = null;
});
}
try {
await refreshOnce;
} catch (e) {
return Promise.reject(e);
}
return httpClient.request(config);
},
);
function toRequestError(error: unknown, fallback: string): Error {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<unknown>;
const responseData = axiosError.response?.data;
if (typeof responseData === "string" && responseData) {
return new Error(responseData);
}
if (responseData && typeof responseData === "object" && "message" in responseData) {
const message = (responseData as Record<string, unknown>).message;
if (typeof message === "string" && message) return new Error(message);
}
return new Error(axiosError.message || fallback);
}
return error instanceof Error ? error : new Error(fallback);
}
/**
* GET JSON支持 query值为 `undefined` 的键不拼接)。
*/
export async function getJson<TResponse>(
path: string,
query?: Record<string, string | number | undefined>,
signal?: AbortSignal,
): Promise<TResponse> {
const sp = new URLSearchParams();
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v === undefined) continue;
sp.set(k, String(v));
}
}
const qs = sp.toString();
const url = qs ? `${path}?${qs}` : path;
try {
const response = await httpClient.get<TResponse>(url, { signal });
return response.data;
} catch (error) {
throw toRequestError(error, "Request failed");
}
}
/**
* POST JSON返回解析后的响应体 `data`。
* @param path 以 `/` 开头的相对路径
*/
export async function postJson<TResponse>(
path: string,
body: JsonBody,
signal?: AbortSignal,
): Promise<TResponse> {
try {
const response = await httpClient.post<TResponse>(path, body, { signal });
return response.data;
} catch (error) {
throw toRequestError(error, "Request failed");
}
}
/** PATCH JSON返回解析后的响应体 `data`。 */
export async function patchJson<TResponse>(
path: string,
body: JsonBody,
signal?: AbortSignal,
): Promise<TResponse> {
try {
const response = await httpClient.patch<TResponse>(path, body, { signal });
return response.data;
} catch (error) {
throw toRequestError(error, "Request failed");
}
}
/** DELETE JSON返回解析后的响应体 `data`(若接口无返回体可使用 `void` 类型接收)。 */
export async function deleteJson<TResponse>(
path: string,
signal?: AbortSignal,
): Promise<TResponse> {
try {
const response = await httpClient.delete<TResponse>(path, { signal });
return response.data;
} catch (error) {
throw toRequestError(error, "Request failed");
}
}
/**
* POST JSON返回原生 `Response`(用于需要直接读 body stream 的场景)。
* 注意:不走 axios 拦截器以外的逻辑;若需鉴权请自行在 headers 中传入。
*/
export async function postStream(
path: string,
body: JsonBody,
signal?: AbortSignal,
): Promise<Response> {
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Stream request failed with status ${response.status}`);
}
return response;
}

48
src/api/paths.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* 客户端 API 路径(均以 `/` 开头)。
* 与 `VITE_API_BASE_URL`、Vite 代理拼接后请求后端。
*
* 与本地 Swagger`http://localhost:3000/docs` → `swagger-ui-init.js` 内嵌 `swaggerDoc`)对照:
* 已对齐 sms/send、sms/login、auth/refresh、chat/completions/stream
* 另有 `chat/sessions`、`chat/sessions/{sessionId}/messages`(见 `chatSessions` 与 `chatSessionMessagesPath`)。
* `auth/logout` 为前端预留,当前嵌入的 OpenAPI 片段中未出现。
*/
export const ApiPath = {
/** 发送短信验证码。POST body: `{ phone, scene }` */
authSmsSend: "/api/client/v1/auth/sms/send",
/** 短信验证码登录。POST body: `{ phone, code }` */
authSmsLogin: "/api/client/v1/auth/sms/login",
/**
* 刷新访问令牌。POST body: `{ refreshToken }`
* 注意:此接口不应携带 `Authorization: Bearer`(见 `http.ts` 拦截器白名单)。
*/
authRefresh: "/api/client/v1/auth/refresh",
/**
* 登出(可选,若后端未实现会失败但本地仍会清理会话)。
* POST body: `{ refreshToken }`;与 refresh 一样不携带 `Authorization`(见 `http.ts` 白名单)。
*/
authLogout: "/api/client/v1/auth/logout",
/** 聊天补全SSE 流式。POST body: `{ messages, model? }` */
chatCompletionsStream: "/api/client/v1/chat/completions/stream",
/** 会话列表/创建Swagger 已声明,业务接入后调用) */
chatSessions: "/api/client/v1/chat/sessions",
} as const;
/** 某会话下的消息列表路径Swagger`/api/client/v1/chat/sessions/{sessionId}/messages` */
export function chatSessionMessagesPath(sessionId: string): string {
return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`;
}
/** 单个会话资源路径Swagger`/api/client/v1/chat/sessions/{sessionId}` */
export function chatSessionPath(sessionId: string): string {
return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
}
/** 会话标题更新路径Swagger`/api/client/v1/chat/sessions/{sessionId}/title` */
export function chatSessionTitlePath(sessionId: string): string {
return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}/title`;
}
/** 请求拦截器跳过附加 Bearer 的路径(与 `ApiPath` 保持一致) */
export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout];

203
src/api/qwenChat.ts Normal file
View File

@@ -0,0 +1,203 @@
/**
* 千问 / 聊天补全流式接口SSE
*
* 使用 `@microsoft/fetch-event-source`,不走 axios因此需在此自行组装请求头
*(含 `Authorization`,与登录后 `localStorage` 中的 `accessToken` 一致)。
*/
import { fetchEventSource } from "@microsoft/fetch-event-source";
import {
ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY,
invalidateSessionAndGoLogin,
persistTokens,
refreshTokens,
SessionExpiredError,
} from "./auth";
import { ApiPath } from "./paths";
/** 携带 HTTP 状态码,便于流式 `onopen` 与外层续签逻辑区分 */
class StreamHttpError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "StreamHttpError";
this.status = status;
}
}
export type ChatRole = "user" | "assistant" | "system";
export interface ChatMessagePayload {
role: ChatRole;
content: string;
}
export interface StreamOptions {
messages: ChatMessagePayload[];
/** OpenAI 兼容字段;不传则由服务端默认模型 */
model?: string;
/** 与会话绑定流式补全时传入(若后端支持) */
sessionId?: string;
/** 是否启用联网搜索OpenAPI: `enableWebSearch` */
enableWebSearch?: boolean;
/** 是否启用深度思考OpenAPI: `enableThinking` */
enableThinking?: boolean;
onToken: (token: string) => void;
signal?: AbortSignal;
/** 超时后中止请求(与业务 `signal` 合并) */
timeoutMs?: number;
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
/** 流式 POST 的请求头JSON + 可选 Bearer */
function streamRequestHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY);
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
return headers;
}
/**
* 从 SSE 单条 JSON 中解析增量文本(兼容多种后端字段)。
*/
function pickTokenFromJson(payload: Record<string, unknown>): string {
const directDelta = payload.delta;
if (typeof directDelta === "string") return directDelta;
const directContent = payload.content;
if (typeof directContent === "string") return directContent;
const text = payload.text;
if (typeof text === "string") return text;
const choices = payload.choices;
if (Array.isArray(choices) && choices.length > 0) {
const first = choices[0] as Record<string, unknown>;
const delta = first.delta as Record<string, unknown> | undefined;
if (delta && typeof delta.content === "string") {
return delta.content;
}
const message = first.message as Record<string, unknown> | undefined;
if (message && typeof message.content === "string") {
return message.content;
}
}
return "";
}
/** 判断是否为用户/超时类中止,便于重试逻辑区分 */
export function isAbortLikeError(error: unknown): boolean {
if (!error) return false;
if (error instanceof DOMException && error.name === "AbortError") return true;
if (error instanceof Error) {
const message = error.message.toLowerCase();
return message.includes("aborted") || message.includes("aborterror");
}
return false;
}
function createTimeoutAbortSignal(timeoutMs: number): AbortSignal {
const controller = new AbortController();
window.setTimeout(
() => controller.abort(new DOMException("Request timeout", "AbortError")),
timeoutMs,
);
return controller.signal;
}
function mergeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal {
const controller = new AbortController();
const onAbort = (event: Event) => {
const source = event.target as AbortSignal;
if (!controller.signal.aborted) controller.abort(source.reason);
};
for (const signal of signals) {
if (!signal) continue;
if (signal.aborted) {
controller.abort(signal.reason);
break;
}
signal.addEventListener("abort", onAbort, { once: true });
}
return controller.signal;
}
function isStreamUnauthorized(error: unknown): boolean {
return error instanceof StreamHttpError && error.status === 401;
}
/**
* 建立 SSE 流式聊天连接,在 `onToken` 中持续收到增量文本。
*
* **401 续签**:首次 `onopen` 为 401 时,用 `refreshToken` 调刷新接口,写入新令牌后**自动重试一次**
* 若仍失败或无 `refreshToken`,则抛出原错误。
*/
export async function streamQwenChat(options: StreamOptions): Promise<void> {
const connectOnce = async () => {
const timeoutSignal = createTimeoutAbortSignal(options.timeoutMs ?? 90000);
const mergedSignal = mergeAbortSignals([options.signal, timeoutSignal]);
await fetchEventSource(`${API_BASE_URL}${ApiPath.chatCompletionsStream}`, {
method: "POST",
headers: streamRequestHeaders(),
body: JSON.stringify({
messages: options.messages,
...(options.model ? { model: options.model } : {}),
...(options.sessionId ? { sessionId: options.sessionId } : {}),
enableWebSearch: !!options.enableWebSearch,
enableThinking: !!options.enableThinking,
}),
signal: mergedSignal,
openWhenHidden: true,
async onopen(response) {
if (!response.ok) {
const errorText = await response.text();
throw new StreamHttpError(
response.status,
errorText || `Stream request failed with status ${response.status}`,
);
}
},
onmessage(event) {
const raw = event.data?.trim();
if (!raw || raw === "[DONE]") return;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const token = pickTokenFromJson(parsed);
if (token) options.onToken(token);
} catch {
options.onToken(raw);
}
},
onerror(error) {
throw error;
},
});
};
try {
await connectOnce();
} catch (error) {
if (!isStreamUnauthorized(error)) throw error;
const storedRefresh = window.localStorage.getItem(REFRESH_TOKEN_KEY);
if (!storedRefresh) {
invalidateSessionAndGoLogin();
throw new SessionExpiredError();
}
try {
const refreshed = await refreshTokens(storedRefresh);
persistTokens(refreshed.accessToken, refreshed.refreshToken);
await connectOnce();
} catch {
invalidateSessionAndGoLogin();
throw new SessionExpiredError();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src/assets/logo-title.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { Button } from "antd";
import { Check, Copy } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import { toast } from "sonner";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github.css";
import type { ReactNode } from "react";
type StreamMessageProps = {
content: string;
};
function extractText(node: ReactNode): string {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (!node) return "";
if (Array.isArray(node)) return node.map((item) => extractText(item)).join("");
if (typeof node === "object" && "props" in node) {
const child = (node as { props?: { children?: ReactNode } }).props?.children;
return extractText(child);
}
return "";
}
function MarkdownPreBlock(props: { children: ReactNode }) {
const [copied, setCopied] = useState(false);
const codeText = extractText(props.children).replace(/\n$/, "");
const onCopy = async () => {
try {
await navigator.clipboard.writeText(codeText);
setCopied(true);
toast.success("代码已复制");
window.setTimeout(() => setCopied(false), 1200);
} catch {
toast.error("复制失败");
}
};
return (
<div className="my-2 overflow-hidden rounded-xl bg-neutral-50">
<div className="flex items-center justify-end px-2 py-1">
<Button
type="text"
size="small"
icon={copied ? <Check size={16} /> : <Copy size={16} />}
className="text-neutral-600 hover:bg-black/5! hover:text-neutral-800!"
onClick={() => {
void onCopy();
}}
>
{copied ? "已复制" : "复制"}
</Button>
</div>
<pre className="m-0 overflow-x-auto p-3 text-[13px] text-neutral-800">{props.children}</pre>
</div>
);
}
export default function StreamMessage(props: StreamMessageProps) {
return (
<div className="rounded-2xl px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800">
<div className="prose prose-sm max-w-none break-words prose-neutral prose-code:before:content-none prose-code:after:content-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex, rehypeHighlight]}
components={{
pre: ({ children }) => <MarkdownPreBlock>{children}</MarkdownPreBlock>,
table: ({ children }) => (
<div className="my-2 overflow-x-auto rounded-lg border border-[var(--ds-border)]">
<table className="w-full border-collapse text-sm">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b border-r border-[var(--ds-border)] bg-neutral-100 px-3 py-2 text-left font-semibold last:border-r-0">
{children}
</th>
),
td: ({ children }) => (
<td className="border-b border-r border-[var(--ds-border)] px-3 py-2 align-top last:border-r-0">
{children}
</td>
),
blockquote: ({ children }) => (
<blockquote className="my-2 border-l-4 border-sky-200 bg-sky-50/60 px-3 py-2 text-neutral-700">
{children}
</blockquote>
),
}}
>
{props.content}
</ReactMarkdown>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
/* eslint-disable react-refresh/only-export-components */
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className,
)}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };

View File

@@ -0,0 +1,68 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,142 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,43 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -1,8 +1,218 @@
@import "tailwindcss";
@import "antd/dist/reset.css" layer(base);
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
*,
*::before,
*::after {
box-sizing: border-box;
}
p {
margin: 0;
}
html,
body,
body {
margin: 0;
height: 100%;
font-size: 14px;
line-height: 1.5;
color: #171717;
background: #ffffff;
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
#root {
margin: 0;
min-height: 100%;
min-height: 0;
height: 100%;
}
/* Ant Layout 默认 min-height 会撑开整页;限制后仅内容区滚动 */
.chat-one-shell.ant-layout {
min-height: 0;
}
.chat-one-shell > .ant-layout {
min-height: 0;
}
.chat-one-shell .ant-layout-content {
min-height: 0;
}
/* DeepSeek 风格近似色 */
:root {
--ds-bg-main: #ffffff;
--ds-bg-sider: #f7f7f8;
--ds-border: #e9e9eb;
--ds-text-secondary: #8a8f99;
--ds-user-bubble: #e8f3ff;
--ds-user-border: #cce7ff;
--ds-active-item: #eaf3ff;
--ds-accent: #1677ff;
--ds-send: #1677ff;
--ds-send-hover: #0f6eea;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #1677ff;
--primary-foreground: #ffffff;
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: #1677ff;
--chart-1: #1677ff;
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #1677ff;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
/* 覆盖 highlight.js github 主题默认白底,保持代码块容器背景一致 */
pre code.hljs {
background: transparent !important;
}
/* 全局 Dropdown 菜单样式 */
.ant-dropdown-menu {
border-radius: 14px !important;
padding: 4px !important;
}
.ant-dropdown-menu-item,
.ant-dropdown-menu-submenu-title,
.ant-dropdown-menu-item-danger {
border-radius: 12px !important;
}
/* 修正 lucide 图标在 AntD Button 中的基线偏移 */
.ant-btn .ant-btn-icon > .lucide {
display: block;
transform: translateY(1px);
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: "Geist Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #1677ff;
--primary-foreground: #ffffff;
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: #1677ff;
--chart-1: #1677ff;
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,15 +1,57 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ConfigProvider, theme } from "antd";
import { BrowserRouter } from "react-router-dom";
import "antd/dist/reset.css";
import { Toaster } from "@/components/ui/sonner";
import "./index.css";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorBgBase: "#F7F7F8",
colorBgContainer: "#FFFFFF",
colorBorder: "#E9E9EB",
colorText: "#1F2329",
colorTextSecondary: "#8A8F99",
borderRadius: 12,
borderRadiusLG: 18,
controlHeight: 36,
fontSize: 14,
boxShadowSecondary:
"0 18px 42px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06)",
},
components: {
Button: {
borderRadius: 10,
controlHeight: 36,
},
Input: {
borderRadius: 12,
},
Select: {
borderRadius: 10,
},
Dropdown: {
borderRadius: 12,
},
Modal: {
contentBg: "#FFFFFF",
headerBg: "transparent",
footerBg: "transparent",
titleColor: "#1F2329",
titleFontSize: 16,
},
},
}}
>
<BrowserRouter>
<App />
</BrowserRouter>
<Toaster position="top-center" />
</ConfigProvider>
</React.StrictMode>,
);

File diff suppressed because it is too large Load Diff

275
src/pages/login.tsx Normal file
View File

@@ -0,0 +1,275 @@
import { toast } from "sonner";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import logoSrc from "../assets/logo.png";
import logoTitleSrc from "../assets/logo-title.png";
import {
ACCESS_TOKEN_KEY,
persistTokens,
persistUser,
sendAuthSmsCode,
smsLogin,
} from "../api/auth";
import { tw } from "../utils/tw";
/** 发送验证码成功后,按钮冷却秒数 */
const SMS_RESEND_COOLDOWN_SEC = 60;
const LOGO_SRC = logoSrc;
const LOGIN_TITLE_SRC = logoTitleSrc;
/** 短信验证码登录页:校验本地 token、发送验证码、提交登录 */
export default function LoginPage() {
const navigate = useNavigate();
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [sendingSms, setSendingSms] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0);
const [loggingIn, setLoggingIn] = useState(false);
const [phoneError, setPhoneError] = useState("");
const [codeError, setCodeError] = useState("");
/** 已登录则直接进入首页,避免重复停留在登录页 */
useEffect(() => {
const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY);
if (accessToken) {
navigate("/", { replace: true });
}
}, [navigate]);
const smsCountdownActive = smsCooldown > 0;
/** 验证码发送冷却:每秒递减,到 0 后允许再次发送 */
useEffect(() => {
if (!smsCountdownActive) return undefined;
const id = window.setInterval(() => {
setSmsCooldown((s) => (s <= 1 ? 0 : s - 1));
}, 1000);
return () => window.clearInterval(id);
}, [smsCountdownActive]);
/**
* 提交手机号与验证码,调用登录接口;成功则持久化 token 并跳转首页。
*/
const onLogin = async () => {
const formattedPhone = formatPhoneForApi(phone);
if (!formattedPhone) {
setPhoneError("请输入正确的手机号");
return;
}
const trimmedCode = code.trim();
if (!trimmedCode) {
setCodeError("请输入验证码");
return;
}
if (loggingIn) return;
setLoggingIn(true);
try {
const data = await smsLogin(formattedPhone, trimmedCode);
persistTokens(data.accessToken, data.refreshToken);
persistUser(data.user);
toast.success("登录成功");
navigate("/", { replace: true });
} catch (error) {
const text = error instanceof Error ? error.message : "登录失败";
toast.error(text || "登录失败");
} finally {
setLoggingIn(false);
}
};
/**
* 将用户输入规范为后端要求的国际格式(如 `+8613xxxxxxxx`)。
* @returns 无法识别时返回 `null`
*/
function formatPhoneForApi(input: string): string | null {
const cleaned = input.trim().replace(/\s+/g, "");
if (!cleaned) return null;
if (cleaned.startsWith("+")) return cleaned;
if (/^86\d{11}$/.test(cleaned)) return `+${cleaned}`;
if (/^1\d{10}$/.test(cleaned)) return `+86${cleaned}`;
return null;
}
/**
* 请求发送短信验证码;成功后启动冷却,若接口返回 `testCode` 则自动填入(联调环境)。
*/
const onSendSmsCode = async () => {
const formattedPhone = formatPhoneForApi(phone);
if (!formattedPhone) {
setPhoneError("请输入正确的手机号");
return;
}
if (sendingSms || smsCooldown > 0) return;
setSendingSms(true);
try {
const { testCode } = await sendAuthSmsCode(formattedPhone);
if (testCode) {
setCode(testCode);
}
setSmsCooldown(SMS_RESEND_COOLDOWN_SEC);
toast.success("验证码已发送");
} catch (error) {
const text = error instanceof Error ? error.message : "验证码发送失败";
toast.error(text || "验证码发送失败");
} finally {
setSendingSms(false);
}
};
/** 手机号、验证码输入组共用外观;由 InputGroup 自身承担边框,避免双层边框 */
const loginGroupClass = tw(
"h-[48px] rounded-full border-gray-200 bg-gray-50 px-4",
"transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-gray-300",
"has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-[#1677ff]/15",
);
const loginGroupStateClass = (error: string) =>
tw(
loginGroupClass,
error
? "border-red-400 has-[[data-slot=input-group-control]:focus-visible]:border-red-400 has-[[data-slot=input-group-control]:focus-visible]:ring-red-500/15"
: "",
);
return (
<main
className={tw(
"relative flex h-dvh w-full items-center justify-center",
"overflow-hidden bg-white px-4",
)}
>
<section className="w-[325px] -translate-y-6">
<div className="mb-7 flex items-center justify-center">
<img
src={LOGO_SRC}
alt="ChatOne Logo"
className="h-10 w-10 shrink-0 object-contain"
decoding="async"
/>
<img
src={LOGIN_TITLE_SRC}
alt="chatone"
className="h-auto w-[150px] object-contain object-left"
decoding="async"
/>
</div>
<div className="space-y-2">
<div>
<InputGroup className={loginGroupStateClass(phoneError)}>
<InputGroupInput
aria-invalid={!!phoneError}
placeholder="请输入手机号"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
if (phoneError) setPhoneError("");
}}
className="h-auto bg-transparent px-0 py-0 text-[14px] md:text-[14px] shadow-none focus-visible:ring-0"
/>
<InputGroupAddon align="inline-start" className="mr-2 text-[14px] text-[#222]">
+86
</InputGroupAddon>
</InputGroup>
<p
className={tw(
"mt-1 min-h-4 px-4 text-xs text-red-500",
phoneError ? "visible" : "invisible",
)}
>
{phoneError || " "}
</p>
</div>
<div>
<InputGroup className={loginGroupStateClass(codeError)}>
<InputGroupInput
aria-invalid={!!codeError}
placeholder="请输入验证码"
value={code}
onChange={(e) => {
setCode(e.target.value);
if (codeError) setCodeError("");
}}
className="h-auto bg-transparent px-0 py-0 text-[14px] md:text-[14px] shadow-none focus-visible:ring-0"
/>
<InputGroupAddon align="inline-end" className="pr-0">
<span className="mx-2 h-5 w-px bg-gray-200" />
<Button
type="button"
variant="link"
size="sm"
disabled={smsCooldown > 0}
onClick={() => {
void onSendSmsCode();
}}
className={tw(
"h-auto min-w-0 shrink-0 p-0 text-[14px] md:text-[14px] font-medium",
"text-[#1677ff] hover:text-[#0f6eea] hover:no-underline",
"disabled:text-neutral-400 disabled:opacity-100",
)}
>
{sendingSms
? "发送中..."
: smsCooldown > 0
? `${smsCooldown}s 后重发`
: "发送验证码"}
</Button>
</InputGroupAddon>
</InputGroup>
<p
className={tw(
"mt-1 min-h-4 px-4 text-xs text-red-500",
codeError ? "visible" : "invisible",
)}
>
{codeError || " "}
</p>
</div>
<p className="block text-[12px] leading-6 text-[#81858c] py-2">
{" "}
<a
href="/user-agreement.html"
target="_blank"
rel="noreferrer"
className="text-[#333] underline"
>
</a>{" "}
{" "}
<a
href="/privacy-policy.html"
target="_blank"
rel="noreferrer"
className="text-[#333] underline"
>
</a>
</p>
<Button
type="button"
size="lg"
disabled={loggingIn}
onClick={() => {
void onLogin();
}}
className="mt-1 h-[46px] w-full rounded-full text-[14px] md:text-[14px]"
>
{loggingIn ? "登录中..." : "登录"}
</Button>
</div>
</section>
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noreferrer"
className="absolute bottom-4 left-1/2 -translate-x-1/2 text-[12px] text-[#a0a3aa] hover:underline"
>
ICP备19037638号-2
</a>
</main>
);
}

View File

@@ -0,0 +1 @@
export { default } from "../index";

6
src/utils/tw.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* 拼接 Tailwind / 任意 class 片段。用于把长 className 拆成多行,避免单行过长。
*/
export function tw(...parts: string[]): string {
return parts.filter(Boolean).join(" ");
}

View File

@@ -12,6 +12,9 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"]
},
"strict": true
},
"include": ["src"]

View File

@@ -1,4 +1,9 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@@ -2,8 +2,14 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import Pages from "vite-plugin-pages";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [
react(),
Pages({
@@ -12,4 +18,14 @@ export default defineConfig({
}),
tailwindcss(),
],
server: {
// 允许局域网通过 IP 访问开发服务
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
});

13797
yarn.lock

File diff suppressed because it is too large Load Diff