Commit 01c8d6fa by DaiJiezhang

feat(login): refine visual experience and remembered username

parent eaad4a3a
import { reactive, ref } from 'vue/dist/vue.esm-bundler.js'; import { reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { signIn } from './auth-store.js'; import { signIn } from './auth-store.js';
/** 代码作用(白话):提供唯一的账号密码登录界面,并只在 Jeddy 成功登录的当次显示专属欢迎提示。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> Auth API -> Cookie/用户状态 -> 路由跳转。 */ import blackHoleImage from '../../../img/black_hole.png';
export default { setup() { const form = reactive({ username: '', password: '' }); const submitting = ref(false); /** 代码作用(白话):提交登录并清空内存中的密码字段,避免页面长期保留密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 -> signIn -> Jeddy 提示/目标路由。 */ async function submit() { submitting.value = true; try { const user = await signIn(form); form.password = ''; if (user.username === 'Jeddy' && user.roleCode === 'DEVELOPER') ElMessage.success('🎉 欢迎系统开发者-BOSS:Jeddy 上线'); window.location.hash = '#/overview'; } catch (_) { ElMessage.error('账号或密码错误,请重试'); } finally { submitting.value = false; } } return { form, submitting, submit }; }, template: `<main class="login-page"><section class="login-card" aria-labelledby="login-title"><p class="eyebrow">XYW CONSOLE</p><h1 id="login-title">登录资产后台</h1><p>请输入由开发者设置的账号和密码。</p><el-form @submit.prevent="submit"><el-form-item label="用户名"><el-input v-model="form.username" autocomplete="username" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item><el-button type="primary" native-type="submit" :loading="submitting" class="login-submit">登录</el-button></el-form></section></main>` };
const REMEMBERED_USERNAME_STORAGE_KEY = 'xyw-asset-console.remembered-username';
/** 代码作用(白话):提供唯一的账号密码登录界面,并只在 Jeddy 成功登录的当次显示专属欢迎提示。关联文件:auth-store.js、AuthController.java、app.css。关联逻辑(调用链/数据流):登录表单 -> Auth API -> Cookie/用户状态 -> 路由跳转。 */
export default {
/** 代码作用(白话):准备登录表单状态,并从浏览器中读取此前选择记住的账号。关联文件:app.css、auth-store.js。关联逻辑(调用链/数据流):浏览器本地存储 -> 表单账号 -> 用户提交登录。 */
setup() {
/** 代码作用(白话):安全读取浏览器本地保存的账号;浏览器禁用本地存储时不影响登录。关联文件:LoginView.js。关联逻辑(调用链/数据流):localStorage -> form.username -> 登录表单。 */
function getRememberedUsername() {
try {
return window.localStorage.getItem(REMEMBERED_USERNAME_STORAGE_KEY) || '';
} catch (_) {
return '';
}
}
const form = reactive({ username: getRememberedUsername(), password: '' });
const rememberUsername = ref(Boolean(form.username));
const submitting = ref(false);
/** 代码作用(白话):按复选框状态仅保存或删除账号名,绝不保存密码和 Token。关联文件:LoginView.js。关联逻辑(调用链/数据流):复选框/登录成功 -> localStorage -> 下次打开表单自动回填。 */
function syncRememberedUsername() {
try {
if (rememberUsername.value && form.username.trim()) {
window.localStorage.setItem(REMEMBERED_USERNAME_STORAGE_KEY, form.username.trim());
return;
}
window.localStorage.removeItem(REMEMBERED_USERNAME_STORAGE_KEY);
} catch (_) {
// 浏览器禁用本地存储时,登录主流程仍可继续。
}
}
/** 代码作用(白话):提交现有账号密码数据、按需记住账号,并在完成后清除页面内存中的密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 submit -> signIn -> 账号本地存储/Jeddy 欢迎提示或通用错误 -> 资产总览。 */
async function submit() {
submitting.value = true;
try {
const user = await signIn(form);
syncRememberedUsername();
form.password = '';
if (user.username === 'Jeddy' && user.roleCode === 'DEVELOPER') ElMessage.success('🎉 欢迎系统开发者-BOSS:Jeddy 上线');
window.location.hash = '#/overview';
} catch (_) {
ElMessage.error('账号或密码错误,请重试');
} finally {
submitting.value = false;
}
}
return { blackHoleImage, form, rememberUsername, submitting, submit, syncRememberedUsername };
},
template: `
<main class="login-page" :style="{ '--login-background-image': 'url(' + blackHoleImage + ')' }">
<div class="login-page__atmosphere" aria-hidden="true"></div>
<section class="login-panel" aria-labelledby="login-title">
<header class="login-brand">
<div class="login-brand__mark" aria-hidden="true"><span></span><span></span><span></span></div>
<div>
<h1 id="login-title">学有为资产管理系统</h1>
</div>
</header>
<el-form class="login-form" @submit.prevent="submit">
<el-form-item class="login-form__item">
<el-input v-model="form.username" class="login-form__input" placeholder="请输入账号" autocomplete="username" aria-label="账号" />
</el-form-item>
<el-form-item class="login-form__item">
<el-input v-model="form.password" class="login-form__input" placeholder="请输入密码" type="password" show-password autocomplete="current-password" aria-label="密码" @keyup.enter="submit" />
</el-form-item>
<el-checkbox v-model="rememberUsername" class="login-form__remember" @change="syncRememberedUsername">记住我的账号</el-checkbox>
<el-button type="primary" native-type="submit" :loading="submitting" class="login-submit">{{ submitting ? '正在登录' : '登录' }}</el-button>
</el-form>
</section>
</main>
`
};
:root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; } :root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; } body { margin: 0; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: linear-gradient(135deg, #132338, #28537d); } /* 登录页:参考黑洞液态玻璃设计,但所有规则以 login- 前缀隔离,避免影响后台中的 Element Plus 表单。 */
.login-card { width: min(420px, 100%); padding: 32px; border-radius: 16px; background: #fff; box-shadow: 0 24px 70px rgba(0,0,0,.24); } .login-page { --login-glass-border: rgba(255, 255, 255, .038); --login-glass-bg-top: rgba(255, 255, 255, .012); --login-glass-bg-bottom: rgba(255, 255, 255, .0015); --login-text-primary: rgba(255, 255, 255, .96); --login-text-secondary: rgba(222, 230, 255, .74); --login-field-border: rgba(211, 225, 255, .055); --login-field-focus: rgba(176, 206, 255, .42); position: relative; display: flex; align-items: center; min-height: 100dvh; overflow: hidden; padding: clamp(24px, 6vw, 88px); color: var(--login-text-primary); background: linear-gradient(90deg, rgba(3, 6, 15, .84) 0%, rgba(3, 7, 18, .64) 38%, rgba(5, 8, 20, .20) 65%, rgba(4, 6, 16, .06) 100%), var(--login-background-image) 68% center / cover no-repeat, #04070e; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; isolation: isolate; }
.login-card h1 { margin: 0 0 8px; }.login-card > p:not(.eyebrow) { margin: 0 0 24px; color: #64748b; }.login-submit { width: 100%; }.logout-button { width: 100%; margin-top: 24px; padding: 10px 12px; border: 1px solid #496b8c; border-radius: 8px; color: #cbd5e1; background: transparent; cursor: pointer; }.logout-button:hover { color:#fff; background:#1f3b59; } .login-page::before { position: absolute; inset: 0; z-index: -1; content: ""; pointer-events: none; background: radial-gradient(circle at 24% 18%, rgba(84, 120, 255, .13), transparent 26%), radial-gradient(circle at 30% 76%, rgba(137, 178, 255, .07), transparent 26%), radial-gradient(circle at 78% 24%, rgba(255, 195, 138, .08), transparent 22%); mix-blend-mode: screen; }
.login-page__atmosphere { position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(112deg, rgba(5, 9, 23, .22), transparent 42%); }
.login-panel { position: relative; width: min(100%, 560px); padding: 34px 34px 32px; overflow: hidden; border: 1px solid var(--login-glass-border); border-radius: 32px; background: linear-gradient(145deg, var(--login-glass-bg-top), var(--login-glass-bg-bottom)); box-shadow: 0 20px 54px rgba(5, 9, 23, .09), inset 0 1px 0 rgba(255, 255, 255, .055), inset 0 -1px 0 rgba(255, 255, 255, .008); backdrop-filter: blur(10px) saturate(112%); -webkit-backdrop-filter: blur(10px) saturate(112%); }
.login-panel::before { position: absolute; inset: -1px; content: ""; pointer-events: none; border-radius: inherit; background: radial-gradient(circle at 18% 0%, rgba(255, 255, 255, .038), transparent 28%), radial-gradient(circle at 100% 100%, rgba(113, 164, 255, .022), transparent 22%), linear-gradient(135deg, rgba(255, 255, 255, .014), transparent); opacity: .48; }
.login-panel::after { position: absolute; top: 12px; right: 14px; left: 14px; height: 92px; content: ""; pointer-events: none; border-radius: 22px; background: linear-gradient(180deg, rgba(255, 255, 255, .012), transparent); opacity: .36; }
.login-brand, .login-panel__intro, .login-form { position: relative; z-index: 1; }
.login-brand { display: flex; align-items: center; justify-content: center; gap: 16px; margin: 0 auto 14px; }
.login-brand__mark { display: grid; grid-template-columns: repeat(3, 4px); gap: 3px; align-content: center; justify-content: center; width: 56px; height: 56px; overflow: hidden; border: 1px solid rgba(255, 255, 255, .18); border-radius: 18px; background: linear-gradient(145deg, rgba(170, 208, 255, .22), rgba(109, 146, 255, .10)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .30), 0 12px 30px rgba(68, 121, 255, .18); }
.login-brand__mark span { width: 4px; border-radius: 999px; background: rgba(255, 255, 255, .92); box-shadow: 0 0 8px rgba(187, 218, 255, .72); }.login-brand__mark span:nth-child(1) { height: 15px; }.login-brand__mark span:nth-child(2) { height: 25px; }.login-brand__mark span:nth-child(3) { height: 19px; }
.login-brand__eyebrow { margin: 0 0 5px; color: rgba(211, 226, 255, .66); font-size: 10px; font-weight: 600; letter-spacing: .16em; }.login-brand h1 { margin: 0; color: var(--login-text-primary); font-size: clamp(22px, 2vw, 28px); font-weight: 700; line-height: 1.25; letter-spacing: .04em; text-shadow: 0 4px 18px rgba(5, 12, 28, .25); white-space: nowrap; }
.login-panel__intro { margin: 0 0 26px; color: var(--login-text-secondary); font-size: 14px; text-align: center; }
.login-form__item.el-form-item { margin-bottom: 16px; }.login-form__input .el-input__wrapper { min-height: 60px; padding: 0 18px; border: 1px solid var(--login-field-border); border-radius: 18px; background: linear-gradient(160deg, rgba(255, 255, 255, .012), rgba(255, 255, 255, .003)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .035), inset 0 -1px 0 rgba(255, 255, 255, .008), 0 6px 18px rgba(5, 10, 22, .025); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: border-color .2s ease, box-shadow .2s ease, background .2s ease, transform .2s ease; }.login-form__input .el-input__wrapper:hover { border-color: rgba(211, 225, 255, .13); }.login-form__input.is-focus .el-input__wrapper { border-color: var(--login-field-focus); background: linear-gradient(160deg, rgba(255, 255, 255, .025), rgba(255, 255, 255, .007)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055), 0 0 0 4px rgba(136, 182, 255, .055), 0 10px 24px rgba(4, 11, 24, .055); transform: translateY(-1px); }.login-form__input .el-input__inner { color: var(--login-text-primary); font-size: 17px; }.login-form__input .el-input__inner::placeholder { color: rgba(224, 231, 255, .58); }.login-form__input .el-input__password { color: rgba(229, 236, 255, .74); }.login-form__input .el-input__password:hover { color: rgba(255, 255, 255, .96); }
.login-submit.el-button { width: 100%; height: 60px; margin-top: 8px; border: 1px solid rgba(205, 224, 255, .18); border-radius: 18px; background: linear-gradient(135deg, rgba(194, 223, 255, .46) 0%, rgba(120, 168, 255, .46) 40%, rgba(84, 127, 255, .50) 100%); color: #fff; font-size: 17px; font-weight: 700; letter-spacing: .08em; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35), 0 18px 30px rgba(60, 103, 207, .18), 0 4px 10px rgba(4, 9, 18, .16); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); transition: transform .2s ease, box-shadow .2s ease, opacity .2s ease, background .2s ease; }.login-submit.el-button:hover { transform: translateY(-1px); background: linear-gradient(135deg, rgba(194, 223, 255, .56), rgba(120, 168, 255, .56) 40%, rgba(84, 127, 255, .60)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .42), 0 22px 36px rgba(60, 103, 207, .22), 0 6px 14px rgba(4, 9, 18, .22); }.login-submit.el-button:active { transform: translateY(1px) scale(.99); }.login-submit.el-button.is-loading, .login-submit.el-button.is-disabled { opacity: .76; transform: none; }
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .login-panel { background: linear-gradient(145deg, rgba(10, 18, 37, .72), rgba(4, 8, 19, .62)); }.login-form__input .el-input__wrapper { background: rgba(7, 15, 32, .72); } }
@media (max-width: 980px) { .login-page { justify-content: center; padding-right: 24px; padding-left: 24px; background-position: 64% center; }.login-panel { margin: 0 auto; } }
@media (max-width: 640px) { .login-page { align-items: center; min-height: 100dvh; padding: 18px; background-position: 63% center; }.login-panel { padding: 26px 22px 24px; border-radius: 26px; }.login-brand { gap: 12px; justify-content: flex-start; }.login-brand__mark { width: 48px; height: 48px; border-radius: 16px; }.login-brand h1 { font-size: clamp(19px, 5.6vw, 22px); letter-spacing: .02em; }.login-brand__eyebrow { font-size: 9px; }.login-panel__intro { margin-bottom: 22px; text-align: left; }.login-form__input .el-input__wrapper, .login-submit.el-button { min-height: 56px; height: 56px; font-size: 16px; }.login-form__input .el-input__inner { font-size: 16px; } }
.logout-button { width: 100%; margin-top: 24px; padding: 10px 12px; border: 1px solid #496b8c; border-radius: 8px; color: #cbd5e1; background: transparent; cursor: pointer; }.logout-button:hover { color:#fff; background:#1f3b59; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); } .app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); }
.sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; } .sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; }
.sidebar h1 { margin: 0 0 32px; font-size: 20px; } .sidebar h1 { margin: 0 0 32px; font-size: 20px; }
...@@ -279,3 +295,250 @@ body { margin: 0; } ...@@ -279,3 +295,250 @@ body { margin: 0; }
@media(max-width:900px){.phone-asset-list-page{padding:26px 0 40px}.phone-asset-list-page__pagination{align-items:flex-start;flex-direction:column}.phone-asset-list-page__pagination .el-pagination{width:100%;justify-content:space-between}} @media(max-width:900px){.phone-asset-list-page{padding:26px 0 40px}.phone-asset-list-page__pagination{align-items:flex-start;flex-direction:column}.phone-asset-list-page__pagination .el-pagination{width:100%;justify-content:space-between}}
@media(max-width:640px){.phone-asset-list-page{padding:20px 0 32px}.phone-asset-list-page__header{align-items:stretch;flex-direction:column;margin-bottom:18px}.phone-asset-list-page__add.el-button{width:100%}.phone-asset-list-page__header h2{font-size:25px}.phone-asset-list-page__search{padding:18px}.phone-asset-list-page__filters,.phone-asset-list-page__filters .el-input,.phone-asset-list-page__filters .el-select,.phone-asset-list-page__filters .el-button{width:100%!important}.phone-asset-list-page__table-header,.phone-asset-list-page__pagination{padding-left:18px;padding-right:18px}.phone-asset-list-page__pagination .el-pagination__jump{display:none}} @media(max-width:640px){.phone-asset-list-page{padding:20px 0 32px}.phone-asset-list-page__header{align-items:stretch;flex-direction:column;margin-bottom:18px}.phone-asset-list-page__add.el-button{width:100%}.phone-asset-list-page__header h2{font-size:25px}.phone-asset-list-page__search{padding:18px}.phone-asset-list-page__filters,.phone-asset-list-page__filters .el-input,.phone-asset-list-page__filters .el-select,.phone-asset-list-page__filters .el-button{width:100%!important}.phone-asset-list-page__table-header,.phone-asset-list-page__pagination{padding-left:18px;padding-right:18px}.phone-asset-list-page__pagination .el-pagination__jump{display:none}}
.wecom-account-page{min-width:0;max-width:100%;overflow-x:hidden}.wecom-account-page__eyebrow{margin:7px 0 0;color:#71717a;font-size:12px;font-weight:700;letter-spacing:.12em}.wecom-account-page .phone-asset-list-page__table,.wecom-account-page__grid{min-width:0;max-width:100%}.wecom-account-page__grid .el-scrollbar__wrap{overflow-x:auto!important}.phone-asset-list-page__external-link{color:#409eff;text-decoration:none}.phone-asset-list-page__external-link:hover{color:#337ecc;text-decoration:none} .wecom-account-page{min-width:0;max-width:100%;overflow-x:hidden}.wecom-account-page__eyebrow{margin:7px 0 0;color:#71717a;font-size:12px;font-weight:700;letter-spacing:.12em}.wecom-account-page .phone-asset-list-page__table,.wecom-account-page__grid{min-width:0;max-width:100%}.wecom-account-page__grid .el-scrollbar__wrap{overflow-x:auto!important}.phone-asset-list-page__external-link{color:#409eff;text-decoration:none}.phone-asset-list-page__external-link:hover{color:#337ecc;text-decoration:none}
/* 登录页视觉微调:输入本体保持透明,玻璃质感由外层承载,避免影响其他业务表单。 */
.login-page {
--login-panel-offset: calc(clamp(100px, 9vw, 180px) + 20px);
background-position: 66% center;
}
.login-page::before {
background: linear-gradient(90deg, rgba(2, 8, 20, .74) 0%, rgba(2, 8, 20, .48) 32%, rgba(2, 8, 20, .16) 57%, rgba(2, 8, 20, .025) 100%);
}
.login-panel {
margin-left: var(--login-panel-offset);
padding: clamp(26px, 3vw, 34px);
background: linear-gradient(145deg, rgba(255, 255, 255, .011), rgba(255, 255, 255, .0012));
border-color: rgba(255, 255, 255, .042);
box-shadow: 0 18px 48px rgba(0, 6, 24, .10), inset 0 1px 0 rgba(255, 255, 255, .045);
backdrop-filter: blur(11px) saturate(113%);
-webkit-backdrop-filter: blur(11px) saturate(113%);
}
.login-brand {
justify-content: center;
gap: 14px;
margin-bottom: 30px;
text-align: center;
}
.login-brand h1 {
font-size: clamp(22px, 2vw, 28px);
white-space: nowrap;
}
.login-form__item.el-form-item {
margin-bottom: 12px;
}
.login-form__item.el-form-item:last-of-type {
margin-bottom: 0;
}
.login-form__input .el-input__wrapper {
background: linear-gradient(160deg, rgba(193, 212, 255, .011), rgba(193, 212, 255, .0015));
border-color: rgba(205, 222, 255, .12);
}
.login-form__input .el-input__wrapper:hover {
background: linear-gradient(160deg, rgba(193, 212, 255, .018), rgba(193, 212, 255, .003));
border-color: rgba(176, 205, 255, .23);
}
.login-form__input .el-input__inner,
.login-form__input .el-input__inner:focus {
color: var(--login-text-primary);
caret-color: var(--login-text-primary);
background: transparent !important;
box-shadow: none !important;
}
.login-form__input .el-input__inner::placeholder {
color: rgba(187, 204, 232, .54);
}
/* Chrome 自动填充会给 input 注入浅色底色;透明内阴影让它继续露出外层玻璃背景。 */
.login-form__input .el-input__inner:-webkit-autofill,
.login-form__input .el-input__inner:-webkit-autofill:hover,
.login-form__input .el-input__inner:-webkit-autofill:focus,
.login-form__input .el-input__inner:-webkit-autofill:active {
-webkit-text-fill-color: var(--login-text-primary) !important;
caret-color: var(--login-text-primary);
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
box-shadow: 0 0 0 1000px transparent inset !important;
background-color: transparent !important;
}
.login-form__input .el-input__suffix-inner {
display: flex;
align-items: center;
}
.login-form__input .el-input__password {
display: grid;
width: 36px;
height: 36px;
margin-right: -10px;
place-items: center;
color: rgba(202, 220, 250, .70);
font-size: 16px;
cursor: pointer;
transition: color .18s ease, background-color .18s ease;
}
.login-form__input .el-input__password svg {
width: 16px;
height: 16px;
}
.login-form__input .el-input__password:hover {
color: #e8f2ff;
background: rgba(139, 180, 255, .08);
border-radius: 50%;
}
.login-form__remember.el-checkbox {
display: inline-flex;
align-items: center;
height: 22px;
margin-top: 14px;
color: rgba(205, 220, 246, .76);
}
.login-form__remember .el-checkbox__inner {
width: 16px;
height: 16px;
border-color: rgba(205, 222, 255, .30);
border-radius: 5px;
background: rgba(193, 212, 255, .025);
}
.login-form__remember:hover .el-checkbox__inner {
border-color: rgba(177, 207, 255, .64);
background: rgba(139, 180, 255, .08);
}
.login-form__remember .el-checkbox__input.is-checked .el-checkbox__inner {
border-color: #83b4ff;
background: linear-gradient(135deg, #a9d4ff, #5d8df2);
}
.login-form__remember .el-checkbox__label {
padding-left: 8px;
color: inherit;
font-size: 13px;
}
.login-submit.el-button {
margin-top: 18px;
background: linear-gradient(135deg, rgba(57, 72, 109, .94) 0%, rgba(48, 59, 96, .95) 52%, rgba(65, 59, 112, .94) 100%);
border-color: rgba(163, 183, 241, .34);
color: #fff;
box-shadow: 0 10px 22px rgba(14, 20, 44, .26), inset 0 1px 0 rgba(218, 228, 255, .18);
}
.login-submit.el-button:hover:not(.is-disabled) {
background: linear-gradient(135deg, rgba(70, 88, 132, .96) 0%, rgba(57, 71, 113, .97) 52%, rgba(77, 69, 132, .96) 100%);
box-shadow: 0 12px 24px rgba(14, 20, 44, .30), inset 0 1px 0 rgba(218, 228, 255, .24);
}
.login-submit.el-button.is-disabled,
.login-submit.el-button.is-loading {
box-shadow: none;
}
@media (max-width: 980px) {
.login-page {
--login-panel-offset: 0px;
background-position: 61% center;
}
.login-panel {
margin-left: 0;
}
}
@media (max-width: 640px) {
.login-page {
background-position: 59% center;
}
.login-brand {
gap: 11px;
margin-bottom: 24px;
}
.login-brand h1 {
font-size: clamp(19px, 6vw, 23px);
}
.login-submit.el-button {
margin-top: 16px;
}
}
/* 登录面板使用视口宽度计算位置,避免桌面端贴近左边缘,同时保留小屏安全边距。 */
.login-page {
position: relative;
min-height: 100vh;
overflow: hidden;
}
.login-panel {
position: absolute;
top: 50%;
left: clamp(280px, 19.2vw, 368px);
width: min(560px, calc(100vw - 48px));
margin-left: 0;
transform: translateY(-50%);
}
/* 登录输入文字在初始、聚焦和浏览器自动填充时使用同一字号,避免点击后出现字号跳变。 */
.login-form__input .el-input__inner,
.login-form__input .el-input__inner:focus,
.login-form__input .el-input__inner:-webkit-autofill,
.login-form__input .el-input__inner:-webkit-autofill:hover,
.login-form__input .el-input__inner:-webkit-autofill:focus,
.login-form__input .el-input__inner:-webkit-autofill:active {
font-size: 16px !important;
line-height: 24px !important;
border: 0 !important;
outline: 0 !important;
-webkit-appearance: none;
appearance: none;
-webkit-text-size-adjust: 100%;
}
/* Chrome/Edge 自动填充会延后重绘浅色背景;禁用延迟并裁剪背景,保持外层玻璃输入框可见。 */
.login-form__input .el-input__inner:-webkit-autofill,
.login-form__input .el-input__inner:-webkit-autofill:hover,
.login-form__input .el-input__inner:-webkit-autofill:focus,
.login-form__input .el-input__inner:-webkit-autofill:active {
-webkit-text-fill-color: var(--login-text-primary) !important;
caret-color: var(--login-text-primary);
background: #091120 !important;
background-color: #091120 !important;
background-image: none !important;
-webkit-background-clip: border-box;
background-clip: border-box;
-webkit-box-shadow: 0 0 0 1000px #091120 inset !important;
box-shadow: 0 0 0 1000px #091120 inset !important;
transition: none !important;
}
@media (max-width: 1200px) {
.login-panel {
left: clamp(48px, 8vw, 96px);
}
}
@media (max-width: 768px) {
.login-panel {
top: 50%;
left: 50%;
width: calc(100vw - 32px);
margin: 0;
transform: translate(-50%, -50%);
}
}
import { expect, test } from '@playwright/test';
const regularUser = { id: 8, username: 'demo_operator', roleCode: 'OPERATIONS', pagePermissions: { overview: 'EDIT' } };
const developerUser = { id: 1, username: 'Jeddy', roleCode: 'DEVELOPER', pagePermissions: { overview: 'EDIT' } };
/** Plain purpose: provide an API envelope matching the production backend without using any real credentials. Related files: auth-api-client.js, AuthController.java. Flow: Playwright route -> fetch response -> auth store/router. */
function envelope(data, message = 'success') { return JSON.stringify({ code: 200, message, data }); }
/** Plain purpose: provide the shared unauthenticated session response used before a login scenario begins. Related files: auth-store.js, router/index.js. Flow: protected route -> GET auth/me -> login route. */
async function mockUnauthenticatedSession(page) {
await page.route('**/api/auth/me', route => route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ code: 401, message: '请先登录', data: null }) }));
await page.route('**/api/auth/csrf', route => route.fulfill({ contentType: 'application/json', body: envelope(null) }));
}
/** Plain purpose: submit one complete credential form so each scenario exercises the real login view bindings. Related files: LoginView.js, auth-store.js. Flow: input values -> submit -> auth API mock -> route/message. */
async function submitLogin(page, username = 'demo_operator', password = 'a-safe-test-password') {
await page.getByRole('textbox', { name: '账号' }).fill(username);
await page.getByRole('textbox', { name: '密码' }).fill(password);
await page.getByRole('button', { name: '登录' }).click();
}
/** Plain purpose: confirm a route guard protects business pages when no valid session exists. Related files: router/index.js, App.js. Flow: browser hash -> auth/me 401 -> login route -> no application shell. */
test('redirects an unauthenticated protected route to login without the sidebar', async ({ page }) => {
await mockUnauthenticatedSession(page);
await page.goto('/asset/#/overview');
await expect(page).toHaveURL(/#\/login$/);
await expect(page.locator('.sidebar')).toHaveCount(0);
});
/** Plain purpose: confirm an ordinary account reaches the overview without the developer-only greeting. Related files: LoginView.js, auth-store.js. Flow: login response -> user state -> overview route. */
test('logs in a regular account without the developer greeting', async ({ page }) => {
await mockUnauthenticatedSession(page);
await page.route('**/api/auth/login', route => route.fulfill({ contentType: 'application/json', body: envelope(regularUser) }));
await page.goto('/asset/#/login');
await submitLogin(page);
await expect(page).toHaveURL(/#\/overview$/);
await expect(page.getByText('欢迎系统开发者-BOSS:Jeddy 上线')).toHaveCount(0);
});
/** Plain purpose: confirm only the fixed developer identity receives its one-time welcome message. Related files: LoginView.js, auth-store.js. Flow: developer login response -> identity condition -> success message. */
test('shows the developer greeting after Jeddy logs in', async ({ page }) => {
await mockUnauthenticatedSession(page);
await page.route('**/api/auth/login', route => route.fulfill({ contentType: 'application/json', body: envelope(developerUser) }));
await page.goto('/asset/#/login');
await submitLogin(page, 'Jeddy');
await expect(page).toHaveURL(/#\/overview$/);
await expect(page.getByText('欢迎系统开发者-BOSS:Jeddy 上线')).toBeVisible();
});
/** Plain purpose: keep a rejected login distinct from an expired authenticated session. Related files: auth-api-client.js, auth-store.js. Flow: login 401 -> generic form error only -> no session-expired browser event. */
test('keeps a rejected login on the form without emitting a session-expired event', async ({ page }) => {
await mockUnauthenticatedSession(page);
await page.route('**/api/auth/login', route => route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ code: 401, message: '账号或密码错误', data: null }) }));
await page.goto('/asset/#/login');
await page.evaluate(() => { window.__loginExpiredEvents = 0; window.addEventListener('xyw-auth-expired', () => { window.__loginExpiredEvents += 1; }); });
await submitLogin(page);
await expect(page).toHaveURL(/#\/login$/);
await expect(page.getByText('账号或密码错误,请重试')).toBeVisible();
await expect.poll(() => page.evaluate(() => window.__loginExpiredEvents)).toBe(0);
await expect(page.getByRole('button', { name: '登录' })).toBeEnabled();
});
/** Plain purpose: ensure the client always returns to the public login route even when server logout fails. Related files: App.js, auth-store.js. Flow: sidebar logout -> API failure -> finally route change -> login page. */
test('returns to login when the logout endpoint fails', async ({ page }) => {
await page.route('**/api/auth/me', route => route.fulfill({ contentType: 'application/json', body: envelope(developerUser) }));
await page.route('**/api/auth/logout', route => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ code: 500, message: 'logout failed', data: null }) }));
await page.goto('/asset/#/overview');
await page.getByRole('button', { name: '退出登录' }).click();
await expect(page).toHaveURL(/#\/login$/);
});
/** Plain purpose: make the image-backed login page observable so an accidental plain-color fallback is caught. Related files: LoginView.js, black_hole.png. Flow: Vite asset import -> CSS custom property -> login background. */
test('renders the login page with its Vite-resolved black-hole background', async ({ page }) => {
await mockUnauthenticatedSession(page);
await page.goto('/asset/#/login');
const backgroundImage = await page.locator('.login-page').evaluate(element => getComputedStyle(element).getPropertyValue('--login-background-image'));
expect(backgroundImage).toContain('url(');
expect(backgroundImage).toContain('black_hole');
});
## Context
`LoginView.js``frontend/img/black_hole.png` 导入为 CSS 自定义属性(CSS 变量,即可由页面传给样式的命名值),`app.css` 再将该图片与遮罩渐变合成为登录页背景。当前实现使用原图,因此静止画面是唯一可作为“一模一样”验收基准的版本;用 Canvas 或 SVG 重新绘制会改变纹理、光照和压缩细节。
本变更让图片的背景定位随鼠标小幅变化,并以同一图片创建只覆盖黑洞区域的遮罩动态层。该层低速旋转以模拟吸积盘自转;登录表单、遮罩与认证业务层保持固定,避免动画影响输入、点击或会话数据。
### 实施范围与文件用途注释
| 文件 | 文件用途注释(白话) | 关联逻辑 | 本次变化 |
| --- | --- | --- | --- |
| `frontend/src/modules/auth/LoginView.js` | 登录界面组件,负责把表单状态和页面交互交给 Vue。 | 指针事件 -> 坐标计算 -> CSS 变量;表单 -> `signIn` -> 路由。 | 仅新增背景偏移状态、事件处理与供样式定位局部旋转层的标记;不改变提交逻辑。 |
| `frontend/src/styles/app.css` | 登录页和后台页面共享样式表,其中 `login-` 前缀规则只作用于登录页。 | CSS 变量 -> 原图背景定位/过渡;遮罩层 -> 局部旋转;媒体查询 -> 无动画回退。 | 将背景图片层、局部旋转层与遮罩层分开,并新增视差、自转和减少动态效果规则。 |
| `frontend/tests/login-experience.spec.js` | 浏览器端登录体验回归测试,使用模拟认证接口而非真实账号。 | Playwright 指针事件/等待动画帧 -> 计算样式 -> 登录页背景。 | 新增验证背景变量、移动后偏移、离开后复位与局部旋转层的用例。 |
### 实施范围内方法的新手注释门禁
| 方法/回调 | 必须添加的注释内容 |
| --- | --- |
| `isBackgroundParallaxEnabled` | 代码作用(白话):判断当前设备是否适合鼠标背景动画。关联文件:`LoginView.js``app.css`。关联逻辑(调用链/数据流):系统动态偏好/精细指针能力 -> 指针处理是否写入 CSS 变量。 |
| `handleBackgroundPointerMove` | 代码作用(白话):把鼠标相对登录区域的位置换算成小范围背景偏移。关联文件:`LoginView.js``app.css`。关联逻辑(调用链/数据流):`pointermove` -> 坐标归一化 -> CSS 变量 -> 背景位置。 |
| `resetBackgroundParallax` | 代码作用(白话):鼠标离开时让背景回到原图的默认裁切位置。关联文件:`LoginView.js``app.css`。关联逻辑(调用链/数据流):`pointerleave`/组件卸载 -> 零偏移 CSS 变量 -> 平滑复位。 |
| 新增 Playwright 测试回调 | 代码作用(白话):验证动画仅改变背景偏移而不替换原图。关联文件:`login-experience.spec.js``LoginView.js`。关联逻辑(调用链/数据流):模拟鼠标 -> DOM 计算样式 -> 原图 URL 与偏移断言。 |
## Goals / Non-Goals
**Goals:**
- 静止状态继续使用同一张 Vite 处理的黑洞图片,保持既有构图、遮罩和登录面板位置。
- 在精细指针设备上,根据鼠标在登录区域的位置平滑改变背景定位,最大偏移受限且不会露出图片边缘。
- 以低速、无限循环的 CSS 动画(由浏览器样式表驱动的画面变化)旋转同源图片的局部遮罩层,形成仅黑洞吸积盘自转的视觉效果。
- 鼠标离开区域后复位;触摸/粗指针设备以及 `prefers-reduced-motion: reduce`(系统声明希望减少动画)保持静止。
- 覆盖可自动执行的浏览器验收,不触发真实登录或写入数据。
**Non-Goals:**
- 不用 Canvas、WebGL、SVG 或 AI 生成资源重画黑洞图;它们只能近似,不能保证与原图一致。
- 不修改登录接口、认证状态、DTO(前后端传递的数据格式)、数据库、路由或表单样式。
- 不为触摸设备加入陀螺仪、拖拽或其他背景交互。
- 不模拟真实的三维引力、粒子或光线追踪物理效果;自转是基于原图的二维视觉模拟。
## Decisions
### 1. 以原图背景定位替代代码重绘
背景继续引用 `black_hole.png`,代码只写入水平、垂直偏移的 CSS 变量;遮罩层与面板不参与移动。
- 选择原因:静止画面复用同一个图片资源,能保留视觉验收的全部像素细节;修改定位可以实现空间感而不更换内容。
- 替代方案:Canvas/WebGL 着色器(由程序计算每个像素颜色的渲染方式)。未采用,因为难以匹配原图纹理,且会增加浏览器兼容、性能与调色成本。
### 2. 使用 Pointer Events 与受限 CSS 变量
登录页根元素监听 `pointermove``pointerleave`,仅接受鼠标/精细指针;处理函数把相对坐标映射到有限像素区间,并通过 CSS 过渡平滑显示。CSS 自定义属性是浏览器原生传值机制,不需要新增依赖或全局状态。
- 选择原因:不会移动表单 DOM,也不会触碰登录请求数据;偏移上限可避免 `cover`(保持图片填满区域的裁切方式)出现空白边缘。
- 替代方案:直接 `transform` 整个 `.login-page`。未采用,因为这会让登录面板和遮罩一起移动,影响输入可用性与视觉稳定性。
### 3. 使用同源遮罩层模拟局部吸积盘自转
在背景图片上叠加一个全尺寸、同源图片图层;该图层通过径向遮罩(让图层只在黑洞附近可见、边缘渐隐的样式)限制影响范围,并围绕黑洞位置以约 30 秒一圈的速率旋转。基础背景、渐变遮罩与登录面板不参与该动画。
- 选择原因:静止时叠加层与原图像素相同;动画时只在黑洞区域产生变化,不会让整幅星空或表单旋转,也无需新增图片资源。
- 替代方案:旋转整个登录页背景。未采用,因为星空和构图会一起旋转,且视觉上不像黑洞本身自转。
### 4. 无动画优先的可访问性回退
JavaScript 在系统请求减少动态效果或设备没有精细指针时不写入偏移;CSS 同时提供 `prefers-reduced-motion` 规则取消过渡,形成双层保护。
- 选择原因:避免动画让触摸设备误触后产生不可预期的画面变化,也尊重用户的系统辅助设置。
- 替代方案:所有设备都启用动画。未采用,因为无法可靠取得触摸位置且会违背减少动态效果偏好。
## Risks / Trade-offs
- [不同屏幕比例下原图裁切不一样] → 保留当前各断点的基础背景位置,只在其周围做小幅偏移,并在桌面与移动视口测试。
- [高频鼠标事件增加样式更新] → 仅写入两个 CSS 变量,不创建网络请求或 DOM 节点;如测试发现掉帧,再使用 `requestAnimationFrame`(浏览器每帧合并更新的机制)合并更新。
- [浏览器不支持 Pointer Events] → 背景保持当前静态画面,登录功能完全不受影响。
- [遮罩边缘与不同视口下的黑洞位置不完全重合] → 复用现有响应式背景定位值,并将遮罩边缘渐隐;在桌面与小屏视口人工检查后再微调位置。
- [局部旋转造成 GPU 绘制压力] → 只创建一个伪元素/装饰层,采用低速 `transform` 动画;减少动态效果设置下关闭动画。
- [测试环境无法模拟系统动态偏好] → 自动测试验证默认鼠标流程和复位;减少动态效果作为手工 smoke 验收项记录。
## Migration Plan
1. 按范围文件逐组实现背景状态、样式和测试,保留原有图片导入与登录提交代码。
2. 执行前端单元/端到端测试与生产构建,并在桌面浏览器手工检查鼠标移动、离开复位、局部自转和登录表单。
3. 若视觉或性能不符合验收,仅撤回新增事件、变量和样式规则;原图背景与认证逻辑无需迁移或数据回滚。
## Open Questions
- 无阻塞问题;默认以当前已验收的 `black_hole.png` 为唯一视觉基准,并采用轻量、低位移的鼠标视差。
## Why
登录页现已通过 `black_hole.png` 呈现黑洞背景,但背景保持静止。若用纯 CSS 或 Canvas 重新绘制,无法保证与原图像素级一致;应保留已验收的原图,并让代码只控制它的观看角度与局部旋转,从而在不改变视觉基准的前提下提供鼠标跟随和吸积盘自转的空间感。
## What Changes
- 为登录页新增基于指针位置的背景视差效果:鼠标移动时,原有黑洞背景在受限范围内平滑偏移。
- 新增仅覆盖黑洞区域的同源图片动态层,以低速循环旋转模拟吸积盘自转;星空、遮罩、登录面板和表单保持静止。
- 保留并继续由 Vite 导入 `frontend/img/black_hole.png`,不以 Canvas、SVG 或生成图替换该资源,确保静止状态的画面与现有背景一致。
- 为离开登录区域、触摸设备及“减少动态效果”系统偏好提供静止或复位行为,避免动画影响可用性。
- 保持账号密码提交、记住账号、路由跳转和认证接口完全不变。
## Capabilities
### New Capabilities
- `login-background-parallax`: 登录黑洞背景保留原始图片视觉,并在适用的指针设备上提供可平滑复位的轻量视差动画和局部吸积盘自转模拟。
### Modified Capabilities
- 无。
## Impact
- 受影响前端文件:`frontend/src/modules/auth/LoginView.js``frontend/src/styles/app.css`
- 不新增运行时依赖;不修改后端接口、DTO(前后端传递的数据格式)、数据库或认证会话。
- 验收将覆盖桌面端鼠标移动、离开区域复位、局部吸积盘自转、小屏/触摸设备静止、减少动态效果偏好,以及既有登录主流程。
## ADDED Requirements
### Requirement: 登录黑洞背景保持原图渲染
系统 SHALL 继续通过 `frontend/img/black_hole.png` 的 Vite 解析 URL 渲染登录页背景。任何背景动画 SHALL 仅改变该图片的展示位置或裁切,不得以 Canvas、SVG、渐变或新图片替换原图。
#### Scenario: 初始登录页显示原图
- **WHEN** 未登录用户打开登录路由且尚未在页面上移动鼠标
- **THEN** `.login-page` 的背景图片变量 SHALL 包含 Vite 解析的黑洞图片 URL,背景偏移 SHALL 为默认值
### Requirement: 精细指针驱动受限背景视差
系统 SHALL 在支持精细指针且未请求减少动态效果的设备上,根据鼠标在登录区域内的相对位置更新背景偏移。偏移 SHALL 被限制在设计定义的小范围内,且不得移动登录表单、遮罩或提交按钮。
#### Scenario: 鼠标在登录区域内移动
- **WHEN** 用户将鼠标从登录区域中心移动到任一边缘方向
- **THEN** 黑洞背景的计算偏移 SHALL 向对应方向平滑变化,且登录面板的几何位置 SHALL 保持不变
#### Scenario: 鼠标离开登录区域
- **WHEN** 用户的鼠标离开登录区域
- **THEN** 背景偏移 SHALL 平滑回到默认值,且原图 URL SHALL 保持不变
### Requirement: 黑洞吸积盘 SHALL 局部自转
系统 SHALL 使用与基础背景相同的黑洞图片 URL 创建局部遮罩动画层,并以低速循环旋转模拟吸积盘自转。遮罩层 SHALL 只影响黑洞附近的视觉区域;基础星空、渐变遮罩、登录面板和表单 SHALL 保持静止。
#### Scenario: 登录页显示局部自转
- **WHEN** 支持精细指针的用户打开登录页且未启用减少动态效果
- **THEN** 黑洞局部动画层 SHALL 使用与基础背景相同的图片 URL 并处于循环旋转状态,登录面板的几何位置 SHALL 保持不变
### Requirement: 不适用设备保持静态背景
系统 SHALL 在触摸/粗指针设备或系统启用 `prefers-reduced-motion: reduce` 时保持背景静态,不因指针事件、视差过渡或吸积盘自转产生持续动画。
#### Scenario: 用户请求减少动态效果
- **WHEN** 浏览器报告 `prefers-reduced-motion: reduce`
- **THEN** 背景 SHALL 使用默认偏移并禁用视差过渡和吸积盘自转,同时登录表单 SHALL 正常可输入和提交
## 1. 登录页视差实现
- [ ] 1.1 在 `frontend/src/modules/auth/LoginView.js` 新增受限背景偏移状态、精细指针/减少动态效果判断、鼠标移动处理和离开复位;为每个新增方法及业务回调补齐“代码作用(白话)/关联文件/关联逻辑(调用链、消息链或数据流)”注释,并保持 `submit`、记住账号与认证调用不变。
- [ ] 1.2 在登录页根元素绑定指针移动和离开事件,仅将偏移 CSS 变量传递给背景层;增加供样式定位局部吸积盘层的结构标记,不让表单面板、遮罩或按钮参与移动。
- [ ] 1.3 在 `frontend/src/styles/app.css` 保留现有黑洞图片、基础裁切和响应式断点,将图片层、局部吸积盘遮罩层与遮罩层分离;为同源局部层添加约 30 秒一圈的低速旋转,且不旋转整幅星空或表单。
- [ ] 1.4 添加受限视差、平滑过渡以及 `prefers-reduced-motion: reduce` 静态回退规则,并同时禁用视差与吸积盘自转。
## 2. 自动化验收
- [ ] 2.1 在 `frontend/tests/login-experience.spec.js` 增加带完整新手注释的 Playwright 用例,断言初始背景仍指向 Vite 解析的黑洞原图,鼠标移动后仅背景偏移变化而面板位置不变,离开后偏移复位。
- [ ] 2.2 增加带完整新手注释的 Playwright 用例,断言局部吸积盘层使用同源图片并处于循环旋转状态,且登录面板不随之移动。
- [ ] 2.3 保留既有普通账号登录、Jeddy 欢迎提示、失败登录和退出失败回归用例,并确认新增测试不请求真实认证服务或写入数据。
## 3. 验证与交付
- [ ] 3.1 在 `frontend` 目录执行既有端到端测试命令,并记录通过/失败结果;失败时先保留证据并分析原因,再决定修复方案。
- [ ] 3.2 在 `frontend` 目录执行生产构建和 `git diff --check`,确认 Vite 仍输出黑洞图片资源且没有空白字符错误。
- [ ] 3.3 在本地桌面浏览器完成 smoke(冒烟检查,即用最小操作确认主流程可用):登录页初始画面、局部吸积盘自转、鼠标四方向移动、离开复位、表单输入/提交、小屏视口及减少动态效果偏好。
## Context
登录页面已经使用黑洞背景和玻璃面板,但最新截图显示两个问题:其一,Chrome/Edge 的自动填充在停留或重绘后仍会向原生 `<input>` 绘制浅色内部矩形;其二,现有面板仍偏左、按钮蓝色过亮,与用户最新提供的目标图不一致。该变更只能调整前端登录页面视觉,不得改变已有登录数据流。
## Goals / Non-Goals
**Goals:**
- 让账号、密码输入框在普通、获得焦点、已自动填充和页面停留后保持同样的深色透明视觉,不再出现浅灰或白色内层矩形。
- 使用实际 Chrome/Edge 验证最终自动填充效果,而不是只依赖构建结果。
- 以最新参考图为基准校准 1920px 桌面布局:面板位于约 19% 视口宽度处,卡片右边缘接近黑洞左侧发光区域但不遮挡亮环。
- 降低主按钮亮度,改为低饱和的深蓝紫渐变,并保留可辨识的主操作状态。
- 在 1200px、768px 断点保留安全边距和居中回退。
**Non-Goals:**
- 不修改登录接口、表单字段、账号记忆的存储键、Token、路由跳转、权限或后端代码。
- 不增加新的第三方依赖或独立演示页面。
## Decisions
### 使用“覆盖浏览器绘制层”而非超长过渡
`input:-webkit-autofill` 同时设置文本颜色、背景裁剪和与字段玻璃层一致的极低透明 inset shadow,并删除 `9999s` 背景过渡。长过渡只能延后浏览器浅色背景出现,不能阻止其重绘;即时覆盖才能在停留后保持稳定。
备选方案是给输入本体设置不透明深色底色。该方案可以隐藏白底,但会破坏用户要求的透明玻璃效果,因此不采用。
### 字号由输入本体统一控制
普通、聚焦和自动填充选择器必须使用同一个显式字号与行高。浏览器自动填充状态不再单独继承或覆盖字号,避免首次进入和点击后视觉跳变。
### 按最新参考图重新校准面板位置与色彩
桌面端将以参考图的约 19% 视口左距作为视觉基准,并使用 `clamp` 计算而非单一像素值;1200px 以下缩小偏移,768px 以下居中。按钮采用暗蓝紫低饱和渐变和轻微边缘高光,避免当前浅蓝按钮脱离黑洞背景。
此前的 `left: clamp(140px, 12.5vw, 240px)` 规则不再作为最终桌面视觉基准,因为最新参考图明确要求更靠右的位置。
## Risks / Trade-offs
- [Chrome 与 Edge 版本对自动填充伪类的绘制差异] → 在两个浏览器中实际触发已保存账号密码并等待重绘后截图验证;必要时采用与外层字段一致的极低透明内阴影作为兼容回退。
- [卡片右移后可能与黑洞亮环重叠] → 以 1920×1080、1440×900、1366×768 三个视口进行截图比对,按参考图微调背景定位和 `clamp` 区间。
- [按钮变暗降低可见性] → 保持白色文字、明确边框与轻微悬停提亮,不牺牲可读性。
## Why
当前登录页在 Chrome/Edge 保存账号密码后,浏览器自动填充会在输入框内注入浅色矩形;现有超长背景过渡又使该矩形在延迟重绘后暴露。与此同时,当前卡片位置和高亮蓝色按钮未达到最新参考图的深色、融入黑洞背景的效果,需要以该参考图作为视觉验收标准。
## What Changes
- 修复 Chrome/Edge 自动填充在页面停留或再次聚焦后出现的浅灰、白色输入框内层背景,并固定各状态的文字尺寸。
- 以最新参考图校准登录卡片的桌面端位置、尺寸、透明玻璃层级和黑洞背景关系;卡片接近黑洞左侧发光区域,但不遮挡最亮环带。
- 将登录按钮从高亮浅蓝改为与背景协调的低饱和深蓝紫渐变,同时保留清晰的主要操作层级。
- 保留当前登录接口、表单输入、账号记忆、Token、状态管理、跳转和权限逻辑。
## Capabilities
### New Capabilities
- `login-visual-stability`: 定义登录页面在浏览器自动填充、焦点变化和不同视口下保持稳定的输入框与视觉布局要求。
### Modified Capabilities
- 无。
## Impact
- 受影响前端文件:`frontend/src/styles/app.css`;如需补充仅用于样式状态的 class,再调整 `frontend/src/modules/auth/LoginView.js`,不改变其业务方法和接口。
- 不影响后端 API、数据库、依赖、鉴权或权限规则。
## ADDED Requirements
### Requirement: 自动填充输入框保持透明稳定
系统 SHALL 使登录页账号和密码输入框在 Chrome/Edge 的普通、聚焦、自动填充及页面停留后的重绘状态下,保持深色透明玻璃外观,不得显示浅灰、白色或浅蓝色内部矩形背景。
#### Scenario: 已保存账号被浏览器自动填充
- **WHEN** 用户打开登录页面,Chrome 或 Edge 自动填入已保存的账号和密码
- **THEN** 输入文字保持可读,输入框内部不出现浅色矩形,外层玻璃边框与背景持续可见
#### Scenario: 自动填充页面停留后重绘
- **WHEN** 自动填充后的登录页保持打开状态并发生浏览器重绘或用户再次聚焦输入框
- **THEN** 输入框外观与首次打开时一致,字号和行高不发生变化
### Requirement: 登录页面匹配黑洞视觉基准
系统 SHALL 在桌面端把登录卡片放置在黑洞左侧区域并与背景融合,卡片不得贴近页面左边缘或遮挡黑洞最亮环带;登录按钮 SHALL 使用低饱和深蓝紫主操作样式而非高亮浅蓝色。
#### Scenario: 1920×1080 桌面视口
- **WHEN** 用户以 1920×1080 打开登录页
- **THEN** 卡片左侧约位于视口宽度 19% 的区域,卡片右边缘接近但不覆盖黑洞亮环,按钮与深色背景协调且文字清晰
#### Scenario: 窄桌面与移动端回退
- **WHEN** 视口宽度低于 1200px 或低于 768px
- **THEN** 卡片分别使用收缩后的安全左边距或水平垂直居中布局,且页面不出现横向滚动条
## 1. 输入框自动填充稳定性
- [x] 1.1 在 `frontend/src/styles/app.css` 移除登录输入框自动填充的超长背景过渡,并为普通、聚焦与自动填充状态统一字号、行高、文本颜色和即时透明覆盖规则。
- [ ] 1.2 在 Chrome 与 Edge 中使用已保存账号密码打开登录页,等待页面停留并重复聚焦输入框,确认不存在浅灰、白色或浅蓝色内部矩形。
## 2. 参考图布局与视觉层级
- [x] 2.1 在 `frontend/src/styles/app.css` 将登录卡片桌面端左距校准到最新参考图约 19% 视口宽度,并以 `clamp` 和 1200px、768px 媒体查询保证小屏安全布局。
- [x] 2.2 将登录按钮调整为低饱和深蓝紫渐变、轻微边框与受控悬停高光,使其与黑洞背景协调。
- [x] 2.3 在 1920×1080、1440×900、1366×768、平板和手机视口截图核对:卡片不遮挡黑洞亮环,页面无横向滚动条。
## 3. 验证
- [x] 3.1 运行 `npm run build`,修复本次样式变更引起的构建错误。
schema: spec-driven
created: 2026-08-03
## Context
当前前端 Playwright 配置会启动本机 Vite 服务,资产页测试通过 `authenticated-test.js` 模拟已登录身份;该文件本身不是 `*.spec.js`,因此并不执行登录页、失败提示或退出路由的测试。黑洞背景图片位于 `frontend/img/black_hole.png`,现已由 Vue 导入以供构建输出,但尚未进入 Git 暂存区。
本变更只补充验收与版本控制,不改变登录请求、Cookie、JWT、路由守卫、数据库或后端。
| 范围文件 | 文件用途(白话) | 关联逻辑 | 风险 |
| --- | --- | --- | --- |
| `frontend/tests/login-experience.spec.js` | 实际执行登录页测试 | Playwright → 模拟 `/api/auth/*``LoginView`/路由守卫 | Mock 与真实响应结构不一致会产生假通过 |
| `frontend/tests/authenticated-test.js` | 已登录测试共用的请求模拟 | 资产测试 → `/api/auth/me` 与 CSRF 模拟 → 路由守卫 | 调整后影响既有资产测试 |
| `frontend/img/black_hole.png` | 登录页黑洞背景资源 | Vue 导入 → Vite 构建输出 → 登录页背景 | 未提交会使其他环境缺图 |
| `frontend/src/modules/auth/LoginView.js` | 被测试的现有登录页面 | 表单 → `signIn` → 欢迎提示/跳转 | 本变更不修改业务逻辑 |
## Goals / Non-Goals
**Goals:**
- 用独立可执行的 `*.spec.js` 覆盖未登录重定向、账号密码提交、失败提示、Jeddy 专属提示、退出跳转与黑洞背景资源。
- 使用与生产接口相同的 `ApiResponse` 信封格式(接口统一返回的 `code/message/data` 结构)模拟响应,避免测试绕过真实前端解析逻辑。
- 将背景图片作为代码提交的一部分。
**Non-Goals:**
- 不测试真实数据库密码、不请求真实后端、不写入账号数据。
- 不修改认证实现、视觉设计、路由行为或新增记住账号等功能。
- 不引入新的测试依赖。
## Decisions
### 1. 使用网络拦截而非真实账号登录
Playwright 将拦截 `/api/auth/login``/api/auth/me``/api/auth/logout``/api/auth/csrf`,并按真实接口形状返回数据。
- 选择原因:测试不需要真实 BCrypt 密码、JWT 密钥或数据库,运行可重复且不会污染测试数据。
- 替代方案:连接本地数据库并创建测试账号。未采用,因为会增加密码、迁移和清理失败的风险。
### 2. 登录场景放入独立 `login-experience.spec.js`
让 Playwright 默认匹配文件名并实际执行;现有 `authenticated-test.js` 仅保留为被资产测试导入的辅助模块。
- 选择原因:测试入口清晰,失败报告能直接定位登录体验。
- 替代方案:将辅助文件重命名为测试文件。未采用,因为它本身没有独立断言,重命名会制造空测试。
### 3. 资源检查采用构建产物与源码路径双重断言
测试验证登录页使用背景图的 CSS 变量,构建命令验证 Vite 产出带哈希的图片文件;Git 暂存验证确保资源随提交传播。
- 选择原因:只检查 DOM 无法证明生产包包含图片;只检查 Git 也无法证明页面引用正确。
- 替代方案:把图片转 Base64 写入 CSS。未采用,因为会扩大样式文件且违背资源独立管理要求。
## Risks / Trade-offs
- [Mock 覆盖不到后端协议变化] → 测试严格采用当前 `code/message/data` 信封,并保留后端 Maven 测试作为另一层校验。
- [登录页文案或提示库异步渲染造成脆弱断言] → 断言可见文本和路由结果,不依赖动画时间或内部 DOM 结构。
- [背景图图片体积约 1.9MB] → 不压缩用户提供的高清资源;构建检查确认其正常输出,并在后续性能工作中单独评估。
## Migration Plan
1. 添加测试并将图片加入 Git 暂存区。
2. 执行 `npm run test:e2e``npm run build`
3. 失败时只回退新增测试和资源暂存,不影响认证与后端代码。
## Open Questions
- 无;测试账号响应使用固定非敏感模拟值,Jeddy 场景仅用于前端提示条件验证。
## Why
当前登录页已经具备真实接口、Cookie 会话和视觉改造,但自动化测试只覆盖已登录的资产页:登录、失败提示、`Jeddy` 专属欢迎提示和退出回到登录页尚未被实际执行。黑洞背景图片也仍是未跟踪文件,后续提交时可能遗漏,导致其他环境缺少登录页背景。
## What Changes
- 新增登录体验端到端测试,覆盖未登录跳转、登录成功、失败提示、仅 `Jeddy` 的欢迎提示和退出返回 `/login`
- 让测试明确模拟认证、CSRF 与退出接口,不依赖真实账号或密码,也不修改生产登录逻辑。
-`frontend/img/black_hole.png` 纳入版本控制,并验证生产构建确实输出该资源。
- 为登录页补充小屏幕与背景资源加载的自动化断言。
## Capabilities
### New Capabilities
- `login-experience-verification`: 为真实登录路由、提示、会话退出和黑洞背景资源提供可重复执行的前端验收覆盖。
### Modified Capabilities
- 无。
## Impact
- 受影响文件:`frontend/tests/``frontend/playwright.config.js`(仅当测试配置确有必要时)、`frontend/img/black_hole.png` 与登录页面测试辅助文件。
- 不修改后端接口、数据库、认证状态管理、路由守卫或生产登录业务逻辑。
- 新增测试只访问本机 Vite 服务并拦截其测试请求。
## ADDED Requirements
### Requirement: 登录体验 SHALL 具有可执行端到端覆盖
系统 SHALL 提供由 Playwright 默认测试匹配规则执行的登录体验测试文件;测试 SHALL 不依赖真实数据库、真实密码或外部服务。
#### Scenario: 未登录用户访问受保护路由
- **WHEN** 测试用户直接打开任一受保护业务 Hash 路由且 `/api/auth/me` 返回未认证结果
- **THEN** 路由 SHALL 显示 `/login` 页面,且不展示后台侧栏
#### Scenario: 普通账号成功登录
- **WHEN** 登录接口返回非 Jeddy 的有效用户资料
- **THEN** 页面 SHALL 请求既有登录接口、跳转至 `/overview`,且不显示开发者专属欢迎提示
#### Scenario: Jeddy 成功登录
- **WHEN** 登录接口返回用户名为 `Jeddy` 且角色为 `DEVELOPER` 的有效用户资料
- **THEN** 页面 SHALL 跳转至 `/overview` 并显示“🎉 欢迎系统开发者-BOSS:Jeddy 上线”
#### Scenario: 登录失败
- **WHEN** 登录接口返回非成功响应
- **THEN** 页面 SHALL 保持在登录页、恢复可提交状态,并显示既有通用错误提示
### Requirement: 退出体验 SHALL 不受服务端登出失败阻塞
系统 SHALL 验证退出操作无论登出接口返回成功或失败,均回到 `/login`;该验证不改变现有生产退出业务逻辑。
#### Scenario: 服务端登出失败
- **WHEN** 已登录用户点击退出且 `/api/auth/logout` 返回错误
- **THEN** 前端 SHALL 清空当前身份并导航至 `/login`
### Requirement: 黑洞背景资源 SHALL 随项目可靠交付
登录页 SHALL 使用 `frontend/img/black_hole.png` 作为独立背景资源;生产构建 SHALL 输出对应图片资源,且该文件 SHALL 被纳入版本控制。
#### Scenario: 生产构建包含背景图片
- **WHEN** 在前端目录执行生产构建
- **THEN** 构建输出 SHALL 包含黑洞图片资源,且登录页的背景变量 SHALL 指向由 Vite 处理的图片 URL
#### Scenario: 克隆后的项目加载背景
- **WHEN** 开发者从版本库获取项目并运行前端构建
- **THEN** 不需要手工复制图片,登录页 SHALL 能解析黑洞背景资源
## 0. 实施范围与新手注释门禁
| 文件 | 文件用途注释 | 实施时必须说明的方法/回调 |
| --- | --- | --- |
| `frontend/tests/login-experience.spec.js` | 可执行的登录体验回归测试 | 每个测试、路由模拟和响应回调须说明“代码作用(白话)/关联文件/关联逻辑(调用链/消息链/数据流)” |
| `frontend/tests/authenticated-test.js` | 已登录测试共用认证模拟 | 如修改模拟方法,说明其对资产页路由守卫的影响 |
| `frontend/img/black_hole.png` | 独立黑洞背景资源 | 不适用方法注释;须核对文件路径和 Git 暂存状态 |
| `frontend/src/modules/auth/LoginView.js` | 现有登录业务页面 | 本变更默认不改;若测试可访问性需要调整,每个新方法须写完整新手注释 |
| `frontend/playwright.config.js` | 本机 Playwright 测试入口 | 如修改启动配置,说明浏览器 → Vite → Mock API 的数据流 |
## 1. 登录体验测试
- [x] 1.1 新建 `login-experience.spec.js`,以默认 Playwright 文件规则实际执行,并使用真实 `code/message/data` 响应信封模拟 `/api/auth/me``/api/auth/login``/api/auth/logout``/api/auth/csrf`
- [x] 1.2 覆盖未登录访问受保护路由跳转 `/login`,并断言后台侧栏不显示。
- [x] 1.3 覆盖普通账号成功登录跳转 `/overview` 且不显示 Jeddy 专属提示。
- [x] 1.4 覆盖 Jeddy 成功登录显示专属欢迎提示,及登录失败保持登录页、恢复按钮状态和显示通用提示。
- [x] 1.5 覆盖登出接口失败时仍然回到 `/login`,防止退出请求错误阻塞前端跳转。
## 2. 背景资源交付
- [x] 2.1 将 `frontend/img/black_hole.png` 加入 Git 暂存区,确认不是被 `.gitignore` 排除的本地临时文件。
- [x] 2.2 在测试中断言登录页存在黑洞背景 CSS 变量或已解析背景 URL,避免页面回退为纯色背景。
- [x] 2.3 执行生产构建,确认 `dist/assets/` 中存在 Vite 输出的黑洞图片资源,且不出现资源路径解析警告。
## 3. 验收
- [x] 3.1 执行 `npm run test:e2e`,输出通过数与失败数;失败时保留错误证据并先分析原因再修复。
- [x] 3.2 执行 `npm run build``git diff --check`,记录构建结果、既有警告和空白检查结果。
- [x] 3.3 执行一次本机浏览器 smoke:登录页背景、表单占位文字、密码显示按钮、加载状态与窄屏不溢出。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment