Compare commits
40 Commits
c206eb8132
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 894eeb8c4b | |||
| 0095291eb0 | |||
| 8018438f42 | |||
| 2f901c5488 | |||
| 20aeb94f6e | |||
| 6e8acef10f | |||
| d93b4d178f | |||
| 891f09aa0d | |||
| 3b0b6eac50 | |||
| b3fdb0ad4b | |||
| 9a1b59663b | |||
| d4a91f11cb | |||
| 6579578a62 | |||
| 056c3e648f | |||
| 038dc5e918 | |||
| f46b50fc1e | |||
| 70e1aa439f | |||
| b1fb19f404 | |||
| d179cd53be | |||
| 7cad29aac2 | |||
| 017919f6bc | |||
| f2a23308b0 | |||
| 2327e3a3b1 | |||
| 6976c0c2eb | |||
| 691d68c59a | |||
| 7917975b5d | |||
| 001af10c2a | |||
| 080881bda1 | |||
| ac3fd70fef | |||
| ba188ff2d8 | |||
| 948a022b77 | |||
| 26de9ad9b8 | |||
| ae2d6419fe | |||
| 5fe57fd087 | |||
| 693835a37e | |||
| 9d1ec850ed | |||
| b1cfd4f04c | |||
| c490bd6f76 | |||
| cd143d9541 | |||
| fa801fe273 |
10
.dockerignore
Normal file
10
.dockerignore
Normal 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
24
.gitea/workflows/ci.yml
Normal 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
|
||||||
48
.gitea/workflows/deploy.yml
Normal file
48
.gitea/workflows/deploy.yml
Normal 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
940
.yarn/releases/yarn-4.14.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
8
.yarnrc.yml
Normal file
8
.yarnrc.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
approvedGitRepositories:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
enableScripts: true
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.14.1.cjs
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal 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
25
components.json
Normal 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
17
docker-compose.yml
Normal 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"
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<title>Chat One Web</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
50
nginx.conf
Normal file
50
nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
package.json
24
package.json
@@ -25,10 +25,29 @@
|
|||||||
"*.{css,json,md,html,yml,yaml,js,mjs,cjs}": "prettier --write"
|
"*.{css,json,md,html,yml,yaml,js,mjs,cjs}": "prettier --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"antd": "^6.3.5",
|
"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": "^19.2.4",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -50,5 +69,6 @@
|
|||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.0",
|
||||||
"vite": "^8.0.3",
|
"vite": "^8.0.3",
|
||||||
"vite-plugin-pages": "^0.33.3"
|
"vite-plugin-pages": "^0.33.3"
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@4.14.1"
|
||||||
}
|
}
|
||||||
|
|||||||
148
public/privacy-policy.html
Normal file
148
public/privacy-policy.html
Normal 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
148
public/user-agreement.html
Normal 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
141
src/api/auth.ts
Normal 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
150
src/api/chatSessions.ts
Normal 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
213
src/api/http.ts
Normal 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
48
src/api/paths.ts
Normal 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
203
src/api/qwenChat.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/assets/apple-touch-icon.png
Normal file
BIN
src/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/assets/favicon.png
Normal file
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
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
BIN
src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
101
src/components/StreamMessage.tsx
Normal file
101
src/components/StreamMessage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/ui/button-group.tsx
Normal file
79
src/components/ui/button-group.tsx
Normal 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 };
|
||||||
68
src/components/ui/button.tsx
Normal file
68
src/components/ui/button.tsx
Normal 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 };
|
||||||
142
src/components/ui/input-group.tsx
Normal file
142
src/components/ui/input-group.tsx
Normal 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,
|
||||||
|
};
|
||||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal 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 };
|
||||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal 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 };
|
||||||
43
src/components/ui/sonner.tsx
Normal file
43
src/components/ui/sonner.tsx
Normal 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 };
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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 };
|
||||||
214
src/index.css
214
src/index.css
@@ -1,8 +1,218 @@
|
|||||||
@import "tailwindcss";
|
@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,
|
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 {
|
#root {
|
||||||
margin: 0;
|
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
6
src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
46
src/main.tsx
46
src/main.tsx
@@ -1,15 +1,57 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { ConfigProvider, theme } from "antd";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import "antd/dist/reset.css";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<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>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</ConfigProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
1168
src/pages/index.tsx
1168
src/pages/index.tsx
File diff suppressed because it is too large
Load Diff
275
src/pages/login.tsx
Normal file
275
src/pages/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/pages/s/[sessionId].tsx
Normal file
1
src/pages/s/[sessionId].tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../index";
|
||||||
6
src/utils/tw.ts
Normal file
6
src/utils/tw.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 拼接 Tailwind / 任意 class 片段。用于把长 className 拆成多行,避免单行过长。
|
||||||
|
*/
|
||||||
|
export function tw(...parts: string[]): string {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { defineConfig } from "vite";
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import Pages from "vite-plugin-pages";
|
import Pages from "vite-plugin-pages";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
Pages({
|
Pages({
|
||||||
@@ -12,4 +18,14 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
|
server: {
|
||||||
|
// 允许局域网通过 IP 访问开发服务
|
||||||
|
host: "0.0.0.0",
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user