chore: 初始化项目结构与登录鉴权模块
后端 FastAPI 六层结构(api/services/repositories/models/schemas/core), 统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存 db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、 登出黑名单(TTL=令牌剩余有效期)、登录日志。 前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。 工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。 测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
commit
273ebf4657
101 files changed
+8725
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# 开发环境配置(纳入版本管理 —— 不含任何敏感信息)
|
||||
#
|
||||
# VITE_ 前缀是 Vite 的约定:只有该前缀的变量会被注入客户端代码。
|
||||
# 因此**绝不要**在此类文件中放密钥 —— 它们会被打包进浏览器可见的产物。
|
||||
|
||||
VITE_APP_TITLE=衍水集团营销人员工资管理系统
|
||||
# 走 Vite 代理,指向相对路径而非后端绝对地址(规范禁止前端硬编码后端 URL)
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_PORT=5173
|
||||
@@ -0,0 +1,8 @@
|
||||
# 生产环境配置(纳入版本管理 —— 不含任何敏感信息)
|
||||
#
|
||||
# 保持相对路径 /api:生产由 Nginx 反向代理到后端。
|
||||
# 若写成后端绝对地址,一是违反「前端禁硬编码后端 URL」的规范,
|
||||
# 二是换个部署域名就要重新打包。
|
||||
|
||||
VITE_APP_TITLE=衍水集团营销人员工资管理系统
|
||||
VITE_API_BASE_URL=/api
|
||||
@@ -0,0 +1,122 @@
|
||||
# 前端
|
||||
|
||||
> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)、`src/views/login/index.vue`(登录页)。当前完成登录链路,业务模块页面待接入。
|
||||
|
||||
## 职责
|
||||
|
||||
- 登录页与图形验证码交互
|
||||
- 会话状态管理与路由守卫
|
||||
- 后续:基础数据、业务数据、绩效核算、报表等业务页面
|
||||
|
||||
## 技术栈与版本约束
|
||||
|
||||
| 组件 | 版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Vue | 3.5.x | |
|
||||
| Vue Router | **5.x** | 新大版本;对 vite/pinia 的 peer 均为 optional |
|
||||
| Pinia | **4.x** | **`@vue/devtools-api` 是其非可选 peer**,必须显式安装,否则装不上或运行时报警 |
|
||||
| Element Plus | 2.14.x | 全量引入,产物约 1MB(gzip 347KB);后续可改按需引入 |
|
||||
| Vite | 8.x | |
|
||||
| TypeScript | **~5.9.3** | **不可用 7.x** —— TS 7 是 Go 重写的原生编译器,而 vue-tsc 的 peer 只写 `>=5.0.0` 拦不住,兼容性未经验证 |
|
||||
| 包管理 | pnpm | |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
├── api/
|
||||
│ ├── request.ts # axios 实例、拦截器、统一信封解包
|
||||
│ └── auth.ts # 认证接口封装与类型
|
||||
├── assets/styles/ # 全局样式
|
||||
├── components/ # 通用组件(待补充)
|
||||
├── composables/ # 组合式函数(待补充)
|
||||
├── router/index.ts # 路由表与全局守卫
|
||||
├── store/
|
||||
│ ├── index.ts # Pinia 实例
|
||||
│ └── user.ts # 会话状态:token、用户信息、登录/登出
|
||||
├── utils/storage.ts # localStorage 读写
|
||||
└── views/
|
||||
├── login/ # 登录页
|
||||
└── dashboard/ # 首页(登录链路验证页)
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 前置:后端已在 127.0.0.1:8000 运行
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173
|
||||
pnpm type-check # vue-tsc 类型检查
|
||||
pnpm build # 类型检查 + 生产构建
|
||||
```
|
||||
|
||||
## 几个关键约定
|
||||
|
||||
**开发期用 Vite 代理,不开 CORS**
|
||||
`/api` 被代理到 `http://127.0.0.1:8000`。用 `127.0.0.1` 而非 `localhost` 是因为 Node 17+ 对 `localhost` 优先解析 IPv6 的 `::1`,而 uvicorn 默认只监听 IPv4。
|
||||
反向地,dev server 自身显式绑定 `host: '127.0.0.1'` —— 不指定时 Vite 只监听 `[::1]`,会出现「`localhost:5173` 能开、`127.0.0.1:5173` 连不上」的困惑。此配置让两种写法都可用。
|
||||
代理成立后 `/api/docs`(Swagger)也能从 5173 端口打开,联调期只需一个域名。
|
||||
|
||||
**401 只表示「登录态失效」**
|
||||
登录接口自身的失败返回 **400/403**,绝不会是 401。因此前端拦截器可以放心地把 401 一律当作「清 token + 跳登录页」处理,不会出现「用户输错一次密码就被弹回登录页」的循环。
|
||||
并发 401 用模块级标志去重,避免同时弹出多条提示并反复跳转。
|
||||
|
||||
**登录失败后必须刷新验证码**
|
||||
后端用 Redis `GETDEL` 原子消费验证码,**任何一次提交后该验证码都已失效**。登录页在 `catch` 分支里调用了 `refreshCaptcha()` —— 漏掉这一步,用户第二次提交必然报「验证码错误」,且极难自查。
|
||||
|
||||
**令牌存 localStorage**
|
||||
实现最简,代价是 XSS 可窃取。缓解手段是 JWT 有效期上限 2 小时,以及后端支持 `token_version` 提版即时失效。若后续要求更高,可改为 httpOnly Cookie,届时只需改 `src/utils/storage.ts` 与后端签发方式。
|
||||
|
||||
## 视觉设计
|
||||
|
||||
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:深水面板 + 流动的层理线,即公司名的意象,也是业务的隐喻。
|
||||
|
||||
**配色**(金色分两档是有意的:亮金只用于深色底、深金只用于浅色底,两处对比度都达标)
|
||||
|
||||
| 名 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 深渊 | `#061A22` | 品牌面板底色 |
|
||||
| 沉碧 | `#0F3646` | 面板渐变、水纹线 |
|
||||
| 青金 | `#C9A227` | **深色底上**的细线与边框 |
|
||||
| 赭金 | `#8F6520` | **浅色底上**的主按钮(配白字约 6.2:1) |
|
||||
| 素纸 | `#FAFAF7` | 表单侧底色 |
|
||||
| 墨 | `#16262D` | 正文 |
|
||||
|
||||
**字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。
|
||||
**界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。
|
||||
|
||||
**签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。
|
||||
|
||||
**构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
|
||||
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
|
||||
|
||||
### 写代码时的三条约定
|
||||
|
||||
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
|
||||
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
|
||||
- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion`
|
||||
|
||||
## 验证码图片
|
||||
|
||||
后端自绘渲染(`backend/app/core/captcha_image.py`),**不再使用 `captcha` 库的默认输出**——那是随机彩色字符 + 重噪点,与界面视觉体系冲突。自绘后字符为墨蓝、底为素纸、干扰线与噪点取自同一套配色。
|
||||
|
||||
识别难度由**逐字随机旋转(±14°)+ 垂直抖动(±2px)+ 正弦干扰曲线 + 双层噪点**共同保证,与原实现相当;笔画加了 1px 描边——本系统用户是营销人员,屏幕与年龄跨度大,验证码过难会直接变成支持负担。
|
||||
|
||||
## 端到端测试
|
||||
|
||||
`e2e/login_flow.py` 用 Playwright 驱动真实浏览器,覆盖 8 项:路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效。
|
||||
|
||||
```bash
|
||||
# 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行
|
||||
python e2e/login_flow.py
|
||||
```
|
||||
|
||||
需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**登录后刷新页面被踢回登录页** —— 检查 `/api/v1/auth/me` 是否可达;路由守卫靠它恢复会话。
|
||||
|
||||
**验证码图片不显示** —— 多为代理未生效。访问 `http://localhost:5173/api/health` 应返回统一信封格式 JSON。
|
||||
|
||||
**`pnpm install` 报 peer 依赖错误** —— 确认 `@vue/devtools-api` 在 `dependencies` 中(pinia 4 的必需 peer)。
|
||||
@@ -0,0 +1,190 @@
|
||||
"""浏览器端端到端验证:登录 + 图形验证码。
|
||||
|
||||
验证的是**只有真实浏览器才能暴露**的问题:
|
||||
|
||||
- 路由守卫是否真的拦住了未登录访问
|
||||
- 验证码图片能否经 Vite 代理正常渲染(跨前后端的完整链路)
|
||||
- 登录失败后验证码是否自动刷新 —— 后端用 GETDEL 消费了验证码,
|
||||
前端不刷新的话第二次提交必然失败,而这个问题在人工测试时极易被忽略
|
||||
- 刷新页面能否用 /me 恢复会话
|
||||
- 登出后令牌是否立即失效
|
||||
|
||||
**运行前置**(缺一不可)::
|
||||
|
||||
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
|
||||
cd backend && uv run uvicorn app.main:app --port 8000 # 后端
|
||||
cd frontend && pnpm dev # 前端
|
||||
|
||||
运行::
|
||||
|
||||
cd frontend && python e2e/login_flow.py
|
||||
|
||||
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5173"
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "Admin@2026"
|
||||
REDIS_CONTAINER = "ys-redis-dev"
|
||||
|
||||
|
||||
def _redis_password() -> str:
|
||||
"""从 docker/.env 读取 Redis 密码。
|
||||
|
||||
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
|
||||
"""
|
||||
if value := os.environ.get("REDIS_PASSWORD"):
|
||||
return value
|
||||
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
|
||||
with open(env_file, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("REDIS_PASSWORD="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
|
||||
|
||||
|
||||
def redis_get(key: str) -> str | None:
|
||||
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
|
||||
result = subprocess.run(
|
||||
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", _redis_password(),
|
||||
"--no-auth-warning", "get", key],
|
||||
capture_output=True, text=True, encoding="utf-8", check=False,
|
||||
)
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def _fail(step: str, detail: str) -> None:
|
||||
print(f" ✗ {step}:{detail}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
captured_captcha_ids: list[str] = []
|
||||
console_errors: list[str] = []
|
||||
|
||||
def on_response(response) -> None:
|
||||
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
||||
return
|
||||
try:
|
||||
captured_captcha_ids.append(response.json()["data"]["captcha_id"])
|
||||
except Exception:
|
||||
# 页面导航后响应体已不可读(Playwright 会抛协议错误)。
|
||||
# 事件回调里未捕获的异常会成为不稳定的噪声来源,故静默跳过。
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
|
||||
|
||||
def at_dashboard() -> bool:
|
||||
"""判断是否真的在首页。
|
||||
|
||||
不能用 `**/dashboard` 这类 glob —— 它会把
|
||||
`/login?redirect=/dashboard` 也匹配上,导致"登录失败"被误判为成功。
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
return urlparse(page.url).path == "/dashboard"
|
||||
|
||||
def toast_text() -> str:
|
||||
locator = page.locator(".el-message")
|
||||
return locator.first.text_content().strip() if locator.count() else ""
|
||||
|
||||
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
|
||||
page.goto(BASE_URL, wait_until="networkidle")
|
||||
if "/login" not in page.url:
|
||||
_fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
||||
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
|
||||
|
||||
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
|
||||
image = page.locator(".captcha__image img")
|
||||
image.wait_for(state="visible", timeout=10_000)
|
||||
src = image.get_attribute("src") or ""
|
||||
if not src.startswith("data:image/png;base64,"):
|
||||
_fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
||||
if len(src) < 1000:
|
||||
_fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
||||
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
|
||||
|
||||
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
|
||||
first_id = captured_captcha_ids[-1]
|
||||
page.fill('input[autocomplete="username"]', USERNAME)
|
||||
page.fill('input[autocomplete="current-password"]', PASSWORD)
|
||||
page.fill('input[autocomplete="off"]', "XXXX")
|
||||
page.click("button:has-text('登 录')")
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
if "/login" not in page.url:
|
||||
_fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
||||
second_id = captured_captcha_ids[-1]
|
||||
if second_id == first_id:
|
||||
_fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
||||
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
|
||||
|
||||
# ---- 4. 正确登录 ----
|
||||
answer = redis_get(f"ys:captcha:{second_id}")
|
||||
if not answer:
|
||||
_fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
||||
page.fill('input[autocomplete="off"]', answer)
|
||||
page.click("button:has-text('登 录')")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
if not at_dashboard():
|
||||
detail = f"仍停留在 {page.url}"
|
||||
if message := toast_text():
|
||||
detail += f",提示:{message}"
|
||||
if console_errors:
|
||||
detail += f",控制台错误:{console_errors[:3]}"
|
||||
_fail("登录", detail)
|
||||
page.wait_for_load_state("networkidle")
|
||||
print(f"✓ 4. 登录成功 跳转到 {page.url}")
|
||||
|
||||
if USERNAME not in page.content():
|
||||
_fail("用户信息", "首页未显示当前用户名")
|
||||
print("✓ 5. 用户信息 首页已展示当前账号")
|
||||
|
||||
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
|
||||
page.reload(wait_until="networkidle")
|
||||
if "/dashboard" not in page.url:
|
||||
_fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
||||
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
|
||||
|
||||
# ---- 7. 登出 → 令牌立即失效 ----
|
||||
page.click("text=退出登录")
|
||||
page.wait_for_url("**/login**", timeout=10_000)
|
||||
print(f"✓ 7. 登出 回到 {page.url}")
|
||||
|
||||
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
|
||||
page.wait_for_timeout(1000)
|
||||
if at_dashboard():
|
||||
_fail("登出失效", "登出后仍能访问受保护页面")
|
||||
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.set_default_timeout(10_000)
|
||||
try:
|
||||
run(page)
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
print("\n全部通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
|
||||
/** 环境变量类型声明,使 import.meta.env.VITE_* 具备补全与类型检查 */
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_PORT?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>衍水集团营销人员工资管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "payroll-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "衍水集团营销人员工资管理系统 —— 前端",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^8.1.5",
|
||||
"axios": "^1.20.0",
|
||||
"element-plus": "^2.14.7",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.43",
|
||||
"vue-router": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.3.4",
|
||||
"vue-tsc": "^3.3.12"
|
||||
}
|
||||
}
|
||||
Generated
+1523
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- vite@8.3.4
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件只承载路由出口;布局由各页面自行决定
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { request } from './request'
|
||||
|
||||
/** 图形验证码 */
|
||||
export interface CaptchaResult {
|
||||
captcha_id: string
|
||||
/** PNG 图片的 base64,**不含** data: 前缀,由前端自行拼接 */
|
||||
image_base64: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
captcha_id: string
|
||||
captcha_code: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
real_name: string | null
|
||||
roles: string[]
|
||||
last_login_time?: string | null
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
/** 获取图形验证码 */
|
||||
export function getCaptcha(): Promise<CaptchaResult> {
|
||||
return request<CaptchaResult>({ url: '/v1/auth/captcha', method: 'get' })
|
||||
}
|
||||
|
||||
/** 登录 */
|
||||
export function login(data: LoginParams): Promise<LoginResult> {
|
||||
return request<LoginResult>({ url: '/v1/auth/login', method: 'post', data })
|
||||
}
|
||||
|
||||
/** 登出 */
|
||||
export function logout(): Promise<null> {
|
||||
return request<null>({ url: '/v1/auth/logout', method: 'post' })
|
||||
}
|
||||
|
||||
/** 获取当前登录用户(刷新页面后用它恢复会话) */
|
||||
export function getCurrentUser(): Promise<UserInfo> {
|
||||
return request<UserInfo>({ url: '/v1/auth/me', method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { clearToken, getToken } from '@/utils/storage'
|
||||
|
||||
/** 后端统一响应信封(见 PROJECT_STANDARDS 第 3 节) */
|
||||
export interface ApiResult<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// 请求拦截:注入 Bearer 令牌
|
||||
service.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
/**
|
||||
* 令牌失效时的跳转去重标志。
|
||||
*
|
||||
* 页面初始化时常常并发多个请求,令牌过期会同时触发多次 401;
|
||||
* 没有这个标志就会弹出多条提示并反复跳转。
|
||||
*/
|
||||
let redirecting = false
|
||||
|
||||
function handleUnauthorized(message: string): void {
|
||||
if (redirecting) return
|
||||
redirecting = true
|
||||
|
||||
clearToken()
|
||||
ElMessage.error(message)
|
||||
|
||||
// 用整页跳转而非 router.replace:既避免 request → router → store → request
|
||||
// 的循环依赖,也能顺带清空内存中的应用状态
|
||||
const current = window.location.pathname + window.location.search
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(current)}`
|
||||
|
||||
window.setTimeout(() => {
|
||||
redirecting = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResult>) => {
|
||||
const body = response.data
|
||||
if (body.code !== 200) {
|
||||
// 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况
|
||||
ElMessage.error(body.message || '请求失败')
|
||||
return Promise.reject(new Error(body.message || '请求失败'))
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiResult>) => {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.message ?? '网络异常,请稍后重试'
|
||||
|
||||
if (status === 401) {
|
||||
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,
|
||||
// 因此不会出现「用户输错一次密码就被弹回登录页」的循环。
|
||||
handleUnauthorized(message)
|
||||
} else {
|
||||
ElMessage.error(message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 发起请求并按统一信封解包。
|
||||
*
|
||||
* 成功时直接返回 `data` 字段,调用方不必层层 `.data`;
|
||||
* 失败时已由拦截器提示并 reject,调用方通常只需 try/catch 做状态回滚。
|
||||
*/
|
||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
const response = await service.request<ApiResult<T>>(config)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export default service
|
||||
@@ -0,0 +1,22 @@
|
||||
/* 全局样式:重置 + 基础版式 */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
background-color: #f5f7fa;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './store'
|
||||
|
||||
import 'element-plus/dist/index.css'
|
||||
import './assets/styles/index.css'
|
||||
|
||||
createApp(App).use(pinia).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { public: true, title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
// 未匹配路由回首页;若未登录会被守卫拦到登录页
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
/**
|
||||
* 全局前置守卫。
|
||||
*
|
||||
* 三层判断:
|
||||
* 1. `meta.public` 标记的页面(登录页)直接放行
|
||||
* 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳
|
||||
* 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话;
|
||||
* 失败则视为令牌已失效,清状态并跳登录页
|
||||
*/
|
||||
router.beforeEach(async (to) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (to.meta.public) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!userStore.token) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if (!userStore.userInfo) {
|
||||
try {
|
||||
await userStore.fetchUserInfo()
|
||||
} catch {
|
||||
userStore.resetAuth()
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = to.meta.title as string | undefined
|
||||
document.title = title ? `${title} - ${import.meta.env.VITE_APP_TITLE}` : import.meta.env.VITE_APP_TITLE
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import * as authApi from '@/api/auth'
|
||||
import { clearToken, getToken, setToken } from '@/utils/storage'
|
||||
|
||||
/**
|
||||
* 用户会话状态。
|
||||
*
|
||||
* 令牌同时存在于 localStorage 与本 store:前者用于刷新页面后恢复,
|
||||
* 后者用于响应式驱动界面。二者只在 login / resetAuth 中同步修改。
|
||||
*/
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string>(getToken())
|
||||
const userInfo = ref<authApi.UserInfo | null>(null)
|
||||
|
||||
const roles = computed<string[]>(() => userInfo.value?.roles ?? [])
|
||||
const displayName = computed<string>(() => userInfo.value?.real_name || userInfo.value?.username || '')
|
||||
|
||||
/** 登录并保存令牌 */
|
||||
async function login(params: authApi.LoginParams): Promise<void> {
|
||||
const result = await authApi.login(params)
|
||||
token.value = result.access_token
|
||||
setToken(result.access_token)
|
||||
userInfo.value = result.user
|
||||
}
|
||||
|
||||
/** 拉取当前用户信息 —— 路由守卫在刷新页面后用它恢复会话 */
|
||||
async function fetchUserInfo(): Promise<void> {
|
||||
userInfo.value = await authApi.getCurrentUser()
|
||||
}
|
||||
|
||||
/** 登出:无论后端是否成功,本地状态都要清干净 */
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// 令牌可能已过期或被拉黑,此时后端会返回 401;
|
||||
// 对用户而言登出已经达成,不必因此报错
|
||||
} finally {
|
||||
resetAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空本地登录态 */
|
||||
function resetAuth(): void {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
clearToken()
|
||||
}
|
||||
|
||||
return { token, userInfo, roles, displayName, login, fetchUserInfo, logout, resetAuth }
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 本地存储读写。
|
||||
*
|
||||
* 令牌单独封装而非在业务代码里直接操作 localStorage:
|
||||
* 一是避免键名各处拼写漂移,二是将来若改为 httpOnly Cookie 存储,
|
||||
* 只需改这一个文件。
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'ys_token'
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await userStore.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-header class="layout-header">
|
||||
<span class="layout-title">衍水集团营销人员工资管理系统</span>
|
||||
<div class="layout-user">
|
||||
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
|
||||
{{ role }}
|
||||
</el-tag>
|
||||
<span class="layout-username">{{ userStore.displayName }}</span>
|
||||
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="layout-main">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>登录状态验证通过</span>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户 ID">{{ userStore.userInfo?.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">
|
||||
{{ userStore.userInfo?.real_name || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">
|
||||
{{ userStore.roles.join('、') || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上次登录">
|
||||
{{ userStore.userInfo?.last_login_time || '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-alert
|
||||
class="layout-hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="这是登录链路的验证页"
|
||||
description="刷新页面可验证会话恢复(守卫会调用 /me);点击右上角退出登录可验证令牌即时失效。业务模块页面将在后续阶段接入。"
|
||||
/>
|
||||
</el-card>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
background-color: #1f4e79;
|
||||
}
|
||||
|
||||
.layout-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.layout-username {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.layout-hint {
|
||||
margin-top: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,478 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getCaptcha } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const captchaLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha_code: '',
|
||||
})
|
||||
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
captcha_code: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ len: 4, message: '验证码为 4 位', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
/** 加载或刷新验证码。点击图片亦可触发。 */
|
||||
async function refreshCaptcha(): Promise<void> {
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
const data = await getCaptcha()
|
||||
captchaId.value = data.captcha_id
|
||||
// 后端返回纯 base64,data URL 前缀由前端拼接
|
||||
captchaImage.value = `data:image/png;base64,${data.image_base64}`
|
||||
form.captcha_code = ''
|
||||
} catch {
|
||||
// 失败提示已由 axios 拦截器统一处理,这里只需保证界面回到可用状态
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!formRef.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await userStore.login({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
captcha_id: captchaId.value,
|
||||
captcha_code: form.captcha_code.trim(),
|
||||
})
|
||||
ElMessage.success('登录成功')
|
||||
await router.replace((route.query.redirect as string) || '/dashboard')
|
||||
} catch {
|
||||
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
|
||||
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
||||
await refreshCaptcha()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshCaptcha)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<!-- 品牌面板:以「水」为意象 —— 公司名衍水,业务是流水之账 -->
|
||||
<section class="brand">
|
||||
<!-- 水纹层理:越往下越密,如潜向深处。纯装饰,对读屏隐藏 -->
|
||||
<svg class="brand-flow" viewBox="0 0 600 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<path class="brand-flow__line" style="--i: 0" d="M-40,150 C 150,95 260,205 420,155 S 620,95 660,120" />
|
||||
<path class="brand-flow__line" style="--i: 1" d="M-40,265 C 170,200 300,320 460,255 S 630,190 660,215" />
|
||||
<path class="brand-flow__line" style="--i: 2" d="M-40,372 C 160,318 320,420 470,360 S 640,300 660,320" />
|
||||
<path class="brand-flow__line" style="--i: 3" d="M-40,470 C 180,428 310,510 480,462 S 645,410 660,425" />
|
||||
<path class="brand-flow__line" style="--i: 4" d="M-40,562 C 150,532 330,596 470,560 S 640,520 660,530" />
|
||||
<path class="brand-flow__line" style="--i: 5" d="M-40,650 C 190,626 300,678 490,648 S 650,618 660,624" />
|
||||
<path class="brand-flow__line" style="--i: 6" d="M-40,732 C 160,714 320,752 460,730 S 640,708 660,712" />
|
||||
<path class="brand-flow__line" style="--i: 7" d="M-40,810 C 175,798 305,826 485,808 S 645,792 660,795" />
|
||||
<path class="brand-flow__line" style="--i: 8" d="M-40,882 C 155,873 330,894 470,880 S 645,868 660,870" />
|
||||
<path class="brand-flow__line" style="--i: 9" d="M-40,946 C 185,939 300,955 490,944 S 650,934 660,935" />
|
||||
</svg>
|
||||
|
||||
<!-- 品牌内容整体居中成块。此前用 space-between 把品牌与标语顶到两端,
|
||||
中间空出大片,两处都成了孤岛;聚成一块才有构图 -->
|
||||
<div class="brand-inner">
|
||||
<div class="brand-block">
|
||||
<h1 class="brand-name">衍水集团</h1>
|
||||
<hr class="brand-rule" />
|
||||
<p class="brand-system">营销人员工资管理系统</p>
|
||||
|
||||
<!-- 断行位置显式控制:中文可在任意字间断行,交给浏览器自动换行
|
||||
会把「楚」这样的单字挤到末行 -->
|
||||
<p class="brand-hero">
|
||||
<span>每一笔回款,</span>
|
||||
<span>都算得清楚</span>
|
||||
</p>
|
||||
<p class="brand-stages">回款 · 计提 · 核算 · 发放</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 表单面板 -->
|
||||
<section class="panel">
|
||||
<div class="panel-inner">
|
||||
<h2 class="panel-title">登录</h2>
|
||||
<p class="panel-hint">请使用管理员分配的账号</p>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
size="large"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
autocomplete="username"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="captcha_code">
|
||||
<div class="captcha">
|
||||
<el-input
|
||||
v-model="form.captcha_code"
|
||||
placeholder="图中字符"
|
||||
maxlength="4"
|
||||
autocomplete="off"
|
||||
class="captcha__input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="captcha__image"
|
||||
:aria-busy="captchaLoading"
|
||||
title="点击换一张"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="验证码" draggable="false" />
|
||||
<span v-else class="captcha__placeholder">加载中</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" class="submit" :loading="submitting" @click="handleSubmit">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p class="panel-foot">衍水集团 · 内部系统</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 配色说明:金色分两档 —— 亮金只用于深色底、深金只用于浅色底,
|
||||
* 保证两处对比度都达标(深金配白字约 6.2:1)。
|
||||
*/
|
||||
.login {
|
||||
--ink-900: #061a22;
|
||||
--ink-800: #0a2833;
|
||||
--ink-700: #0f3646;
|
||||
--ink-600: #1a4f63;
|
||||
--gold-bright: #c9a227;
|
||||
--gold-deep: #8f6520;
|
||||
--paper: #fafaf7;
|
||||
--text: #16262d;
|
||||
--muted: #6b7b84;
|
||||
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ============ 品牌面板 ============ */
|
||||
|
||||
.brand {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 56%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(158deg, var(--ink-800) 0%, var(--ink-900) 62%, #04141b 100%);
|
||||
}
|
||||
|
||||
/* 水下的光:给居中的内容一个落点,否则深色面板会显得空 */
|
||||
.brand::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background: radial-gradient(58% 46% at 32% 46%, rgb(32 116 146 / 26%), transparent 72%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-flow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.brand-flow__line {
|
||||
fill: none;
|
||||
stroke: var(--ink-600);
|
||||
stroke-width: 1;
|
||||
opacity: 0.55;
|
||||
/* 逐条画出:一个统一的入场动作,而非散落特效 */
|
||||
stroke-dasharray: 1200;
|
||||
stroke-dashoffset: 1200;
|
||||
animation: flow-draw 2.4s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
|
||||
animation-delay: calc(var(--i) * 0.13s);
|
||||
}
|
||||
|
||||
@keyframes flow-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 56px clamp(40px, 6vw, 104px);
|
||||
}
|
||||
|
||||
/* 内容成块并限宽:超宽屏下文字块不会被拉散 */
|
||||
.brand-block {
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
margin: 0;
|
||||
font-family: 'Source Han Serif SC', 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
|
||||
font-size: clamp(36px, 3.6vw, 54px);
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.2em;
|
||||
color: #f2f6f7;
|
||||
}
|
||||
|
||||
.brand-rule {
|
||||
width: 52px;
|
||||
height: 2px;
|
||||
margin: 24px 0 18px;
|
||||
background-color: var(--gold-bright);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.brand-system {
|
||||
margin: 0;
|
||||
font-size: clamp(13px, 1.1vw, 16px);
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.28em;
|
||||
color: #9db6c0;
|
||||
}
|
||||
|
||||
.brand-hero {
|
||||
margin: 58px 0 0;
|
||||
font-size: clamp(16px, 1.4vw, 21px);
|
||||
font-weight: 400;
|
||||
line-height: 1.8;
|
||||
letter-spacing: 0.06em;
|
||||
color: #d9e5ea;
|
||||
}
|
||||
|
||||
.brand-hero span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 三个业务环节:用间隔点连成一行,比带框的标签轻得多,
|
||||
也不会被误读成可点击的按钮 */
|
||||
.brand-stages {
|
||||
margin: 18px 0 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.24em;
|
||||
color: #6f8b97;
|
||||
}
|
||||
|
||||
/* ============ 表单面板 ============ */
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex: 1 1 44%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px clamp(28px, 5vw, 80px);
|
||||
background-color: var(--paper);
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
width: 100%;
|
||||
max-width: 356px;
|
||||
animation: panel-rise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) 0.35s both;
|
||||
}
|
||||
|
||||
@keyframes panel-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
margin: 0 0 30px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.captcha {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha__input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha__input :deep(input) {
|
||||
font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.captcha__image {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.captcha__image:hover {
|
||||
border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
.captcha__image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.captcha__placeholder {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.form-actions :deep(.el-form-item__content) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 覆盖 Element Plus 的按钮变量而非直接写 background-color ——
|
||||
后者与 .el-button--primary 同权重,会受样式注入顺序影响而不稳定 */
|
||||
.submit {
|
||||
width: 100%;
|
||||
letter-spacing: 0.4em;
|
||||
--el-button-bg-color: var(--gold-deep);
|
||||
--el-button-border-color: var(--gold-deep);
|
||||
--el-button-hover-bg-color: #a3762a;
|
||||
--el-button-hover-border-color: #a3762a;
|
||||
--el-button-active-bg-color: #7d5719;
|
||||
--el-button-active-border-color: #7d5719;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin: 32px 0 0;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
color: #9aa8af;
|
||||
}
|
||||
|
||||
/* 输入框聚焦态也落在同一套配色上 */
|
||||
.panel :deep(.el-input__wrapper) {
|
||||
--el-input-focus-border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
/* ============ 响应式 ============ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 0 0 auto;
|
||||
padding: 36px 26px 32px;
|
||||
}
|
||||
|
||||
.brand-inner {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-flow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 30px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.brand-rule {
|
||||
margin: 16px 0 12px;
|
||||
}
|
||||
|
||||
/* 窄屏上只留品牌识别,标语与环节行让位给表单 */
|
||||
.brand-hero,
|
||||
.brand-stages {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
padding: 38px 26px 46px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 尊重系统的「减弱动效」偏好:保留终态,去掉过渡 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand-flow__line {
|
||||
stroke-dashoffset: 0;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vite/client"],
|
||||
"paths": {
|
||||
// 自 TypeScript 4.1 起 paths 不再需要 baseUrl —— 未指定时按
|
||||
// tsconfig.json 所在目录解析,结果与原先一致。
|
||||
// 保留 baseUrl 会被新版 TypeScript 标记为已弃用选项。
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: Number(env.VITE_PORT ?? 5173),
|
||||
// 显式绑定 IPv4 回环。不指定时 Vite 在本机只监听 [::1]:5173,
|
||||
// 于是 localhost:5173 能开、127.0.0.1:5173 却连不上 ——
|
||||
// 这种"换个写法就坏"的差异排查起来很费时间。
|
||||
host: '127.0.0.1',
|
||||
proxy: {
|
||||
// 后端路由本身带 /api 前缀,因此不做 rewrite
|
||||
//
|
||||
// 目标地址用 127.0.0.1 而非 localhost:Node 17+ 对 localhost 优先解析
|
||||
// IPv6 的 ::1,而 uvicorn 默认只监听 127.0.0.1,用 localhost 会连不上。
|
||||
//
|
||||
// 开发期走代理而非后端 CORS,符合规范「跨域在网关统一处理」;
|
||||
// 副作用是 /api/docs(Swagger)也能从 5173 端口打开,联调期只用一个域名。
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user