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:
liuwenxinandClaude Code committed 2026-10-09 18:46:36 +08:00
commit 273ebf4657
101 files changed
+8725

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
// 根组件只承载路由出口;布局由各页面自行决定
</script>
<template>
<router-view />
</template>
+51
View File
@@ -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' })
}
+88
View File
@@ -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
+22
View File
@@ -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;
}
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+12
View File
@@ -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')
+65
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
export default pinia
+53
View File
@@ -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 }
})
+21
View File
@@ -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)
}
+98
View File
@@ -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>
+478
View File
@@ -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>