diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index dcd8f2e..a3f935a 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -560,7 +560,7 @@ export function App() { initialMode={authMode} onModeChange={(next) => { setAuthMode(next); - window.history.pushState(null, "", "/login"); + window.history.pushState(null, "", next === "register" ? "/register" : "/login"); }} onAuthed={onAuthed} /> diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index f4c4bb5..637ed4e 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -6,6 +6,7 @@ import type { BillingTrend, Ledger, LoginSession, + Invitation, ModelConfig, Notification, NotificationList, @@ -105,7 +106,7 @@ async function request(path: string, options: RequestInit = {}): Promise { } export const api = { - register(payload: { username: string; password: string; email?: string; team_name?: string }) { + register(payload: { username: string; password: string; email?: string; team_name?: string; invite_code?: string }) { return request("/api/auth/register/", { method: "POST", body: JSON.stringify(payload) }); }, login(payload: { username: string; password: string }) { @@ -172,6 +173,15 @@ export const api = { body: JSON.stringify({ password }) }); }, + listInvitations() { + return request("/api/auth/team/invitations/"); + }, + createInvitation(payload: { role?: string; email?: string; monthly_credit_limit?: number | string }) { + return request("/api/auth/team/invitations/", { method: "POST", body: JSON.stringify(payload) }); + }, + revokeInvitation(id: string) { + return request(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" }); + }, products() { return request>("/api/products/"); }, diff --git a/core/frontend/src/routes/auth-screen.tsx b/core/frontend/src/routes/auth-screen.tsx index b386f9a..47d9a05 100644 --- a/core/frontend/src/routes/auth-screen.tsx +++ b/core/frontend/src/routes/auth-screen.tsx @@ -1,6 +1,18 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; -import { Eye, EyeOff, Info, LockKeyhole, UserRound } from "lucide-react"; +import { + ArrowRight, + Check, + Eye, + EyeOff, + Info, + LockKeyhole, + ScanLine, + ShieldCheck, + Ticket, + UserRound, + Users +} from "lucide-react"; import { api, getRemember, setRemember as persistRemember } from "../api"; import type { Team, User } from "../types"; import type { AuthMode } from "./route-config"; @@ -17,9 +29,24 @@ type FieldErrors = { password?: string; }; +type RegisterErrors = { + team?: string; + username?: string; + password?: string; + password2?: string; + agree?: string; +}; + +const VALUE_POINTS: { strong: string; rest: string }[] = [ + { strong: "5 阶段流水线", rest: "脚本 → 资产 → 故事板 → 视频片段 → 拼接导出" }, + { strong: "失败不扣费", rest: "任务仅在用户通过时扣费,失败 / 超时 / 重跑一律不扣" }, + { strong: "团队 + 角色 + 四层额度", rest: "超管 / 团管 / 成员,日 / 月 + 团队 / 总四层防超支" }, + { strong: "跨项目资产库", rest: "主播 / 场景 / 商品图沉淀复用,不重复生成" } +]; + export function AuthScreen({ - initialMode: _initialMode, - onModeChange: _onModeChange, + initialMode, + onModeChange, onAuthed }: { initialMode: AuthMode; @@ -27,23 +54,37 @@ export function AuthScreen({ onAuthed: (payload: { token: string; user: User; team: Team; remember?: boolean }) => void | Promise; }) { const remembered = getRemember(); + const [mode, setMode] = useState(initialMode); + + // ── 登录态 ── const [username, setUsername] = useState(remembered?.username || ""); const [password, setPassword] = useState(""); const [remember, setRemember] = useState(Boolean(remembered)); const [showPwd, setShowPwd] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); + + // ── 注册态 ── + const [regTeam, setRegTeam] = useState(""); + const [regUsername, setRegUsername] = useState(""); + const [regPassword, setRegPassword] = useState(""); + const [regPassword2, setRegPassword2] = useState(""); + const [regInvite, setRegInvite] = useState(""); + const [regAgree, setRegAgree] = useState(true); + const [regShowPwd, setRegShowPwd] = useState(false); + const [regShowPwd2, setRegShowPwd2] = useState(false); + const [regErrors, setRegErrors] = useState({}); + + // ── 共用 ── const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [toast, setToast] = useState<{ title: string; sub: string } | null>(null); const [loginProgress, setLoginProgress] = useState(null); const toastTimer = useRef | null>(null); + // initialMode 由路由驱动(/login ↔ /register),外部回退/前进时跟随 useEffect(() => { - const path = window.location.pathname.replace(/\/+$/, "").toLowerCase(); - if (path === "/register" || window.location.hash === "#register") { - window.history.replaceState(null, "", "/login"); - } - }, []); + setMode(initialMode); + }, [initialMode]); useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []); function showToast(title: string, sub: string) { @@ -52,6 +93,17 @@ export function AuthScreen({ toastTimer.current = setTimeout(() => setToast(null), 2800); } + function switchMode(next: AuthMode) { + if (next === mode) return; + setMode(next); + setError(""); + setLoginProgress(null); + setFieldErrors({}); + setRegErrors({}); + onModeChange(next); + } + + // ── 登录 ── function setFieldValue(field: keyof FieldErrors, value: string) { if (field === "username") setUsername(value); if (field === "password") setPassword(value); @@ -72,7 +124,7 @@ export function AuthScreen({ return true; } - async function submit(event?: FormEvent) { + async function submitLogin(event?: FormEvent) { event?.preventDefault(); if (busy) return; if (!validateLogin()) return; @@ -98,22 +150,259 @@ export function AuthScreen({ } } + // ── 注册 ── + function setRegValue(field: keyof RegisterErrors, value: string) { + if (field === "team") setRegTeam(value); + if (field === "username") setRegUsername(value); + if (field === "password") setRegPassword(value); + if (field === "password2") setRegPassword2(value); + setError(""); + setRegErrors((current) => ({ ...current, [field]: undefined })); + } + + function validateRegister() { + const next: RegisterErrors = {}; + if (!regTeam.trim()) next.team = "请输入团队名"; + if (!regUsername.trim()) next.username = "请输入用户名"; + if (!regPassword) next.password = "请输入密码"; + else if (regPassword.length < 8) next.password = "密码至少 8 位"; + if (!regPassword2) next.password2 = "请再次输入密码"; + else if (regPassword && regPassword2 !== regPassword) next.password2 = "两次密码不一致"; + if (!regAgree) next.agree = "请阅读并同意用户协议"; + setRegErrors(next); + if (Object.keys(next).length > 0) { + setError("请补全必填字段后再创建团队"); + return false; + } + return true; + } + + async function submitRegister(event?: FormEvent) { + event?.preventDefault(); + if (busy) return; + if (!validateRegister()) return; + setBusy(true); + setError(""); + setLoginProgress("entering"); + try { + const invite = regInvite.trim(); + const payload = await api.register({ + username: regUsername.trim(), + password: regPassword, + team_name: regTeam.trim(), + ...(invite ? { invite_code: invite } : {}) + }); + // 注册即登录:记住新账号用户名,与登录一致地交给 onAuthed 完成身份+数据水合 + persistRemember(regUsername.trim(), true); + await onAuthed({ ...payload, remember: true }); + } catch (err) { + setLoginProgress(null); + const raw = err instanceof Error ? err.message : ""; + const friendly = /already exists|unique|taken/i.test(raw) + ? "该用户名已被占用,换一个试试" + : /invite|invitation/i.test(raw) + ? "邀请码无效或已过期" + : raw || "创建团队失败,请稍后重试"; + setError(friendly); + } finally { + setBusy(false); + } + } + + const brand = ( + + ); + + if (mode === "register") { + return ( +
+
+ + {brand} +
+

注册团队

// /auth/register
+

填写团队信息开通账户,默认成为团队超管。

+
+
+ +
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+ +
+
+

+ {regInvite.trim() ? "填了码:将加入已有团队" : "没填码:将开新团队,你是超管"} +

+
+ + + + {loginProgress && !error && ( +
+ + 正在创建团队,进入 Airshelf… +
+ )} + {error &&
{error}
} + + + + +
+
+
+ {toast && ( +
+
+
{toast.title}// {toast.sub}
+
+ )} +
+ ); + } + return (
- + {brand}
-

登录

// LOGIN
-
+

登录

// /auth/login
+ +

用团队账号登录,进入你的工作台与生产管线。

@@ -168,9 +457,21 @@ export function AuthScreen({
)} {error &&
{error}
} - +
OR
+
+ + +
+
{toast && ( diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index db01736..84392fb 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -33,7 +33,7 @@ export type Page = | "settings" | "settingsNotify"; -export type AuthMode = "login"; +export type AuthMode = "login" | "register"; export type ResolvedRoute = { page: Page; authMode: AuthMode; @@ -111,7 +111,7 @@ export function resolveRoute(): ResolvedRoute { const search = new URLSearchParams(window.location.search); const hash = window.location.hash.replace("#", ""); - if (path === "/register" || hash === "register") return { page: "dashboard", authMode: "login", hash }; + if (path === "/register" || hash === "register") return { page: "dashboard", authMode: "register", hash }; if (path === "/login") return { page: "dashboard", authMode: "login", hash }; if (path === "/" && isPage(hash)) return { page: hash, authMode: "login", hash }; if (path === "/" || path === "/dashboard") return { page: "dashboard", authMode: "login", hash }; diff --git a/core/frontend/src/styles.css b/core/frontend/src/styles.css index 7cb0da5..639e6bd 100644 --- a/core/frontend/src/styles.css +++ b/core/frontend/src/styles.css @@ -1075,6 +1075,213 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); } font-weight: 400; } +/* lead 引导文案 + mono 副标(登录补、注册自带) */ +.auth-exact-page .auth-form .lead { + font-size: 13px; + color: var(--black-alpha-56); + margin: 0 0 22px; + line-height: 1.6; +} +.auth-exact-page.auth-login-page .auth-form .lead { margin: 0 0 20px; } + +/* OR 分割线 + 第三方 SSO(登录页) */ +.auth-exact-page .divider { + display: flex; + align-items: center; + gap: 10px; + margin: 18px 0 14px; +} +.auth-exact-page .divider .line { flex: 1; height: 1px; background: var(--border-faint); } +.auth-exact-page .divider .txt { + font-family: var(--font-mono); + font-size: 12px; + color: var(--black-alpha-32); + letter-spacing: .08em; + text-transform: uppercase; +} +.auth-exact-page .sso-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.auth-exact-page .sso-btn { + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + padding: 10px 12px; + font-size: 13px; + color: var(--accent-black); + cursor: pointer; + font-family: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: border-color var(--t-base); +} +.auth-exact-page .sso-btn:hover { border-color: var(--black-alpha-32); } +.auth-exact-page .sso-btn svg { width: 14px; height: 14px; } + +/* 登录 / 注册切换行 */ +.auth-exact-page .switch-row { + margin-top: 18px; + text-align: center; + font-size: 13px; + color: var(--black-alpha-56); +} +.auth-exact-page .switch-row a { + color: var(--heat); + text-decoration: none; + font-weight: 500; +} +.auth-exact-page .switch-row a:hover { text-decoration: underline; } + +/* ── 注册态:表单更宽 + 自然流(不再绝对定位 .auth-form) ── */ +.auth-exact-page.auth-register-page .auth-wrap { + height: auto; + min-height: 620px; + align-items: stretch; +} +.auth-exact-page.auth-register-page .auth-brand { + flex: 0 0 calc(100% - 460px); + width: calc(100% - 460px); +} +.auth-exact-page.auth-register-page .auth-form { + position: static; + width: 460px; + flex: 0 0 460px; + height: auto; + min-height: 0; + align-self: stretch; + padding: 40px 44px 32px; +} +.auth-exact-page.auth-register-page .auth-form form { + flex: 0 0 auto; + margin-top: 22px; +} +.auth-exact-page.auth-register-page .auth-form .h-row { gap: 6px; } +.auth-exact-page.auth-register-page .auth-form h2 { font-size: 24px; } + +/* 注册:品牌区顶对齐 hero + 价值点列表 */ +.auth-exact-page .auth-brand .hero.hero-top { + margin-top: 26px; + margin-bottom: 0; +} +.auth-exact-page .auth-brand .hero.hero-top h1 { font-size: 28px; } +.auth-exact-page .val-list { + margin: 28px 0 0; + display: flex; + flex-direction: column; + gap: 14px; +} +.auth-exact-page .val-item { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 12px; + align-items: start; +} +.auth-exact-page .val-item .ic-v { + width: 22px; + height: 22px; + border: 1px solid rgba(255, 255, 255, .18); + border-radius: var(--r-sm); + display: grid; + place-items: center; + color: var(--heat); +} +.auth-exact-page .val-item .ic-v svg { width: 12px; height: 12px; } +.auth-exact-page .val-item .txt-v { + font-size: 13px; + color: rgba(255, 255, 255, .78); + line-height: 1.5; +} +.auth-exact-page .val-item .txt-v b { color: var(--accent-white); font-weight: 600; } + +/* 注册:label 行尾提示 + 双列密码 + 邀请码动态提示 + 协议勾选 */ +.auth-exact-page .field-label .hint { + margin-left: auto; + font-size: 11.5px; + font-weight: 400; + color: var(--black-alpha-32); +} +.auth-exact-page .field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} +.auth-exact-page .field-row .field { margin-bottom: 0; } +.auth-exact-page .field-note { + margin: 7px 0 0; + font-size: 12px; + color: var(--black-alpha-48); + font-family: var(--font-mono); + letter-spacing: .02em; +} +.auth-exact-page .agree { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12.5px; + color: var(--black-alpha-56); + line-height: 1.6; + margin: 8px 0 18px; + cursor: pointer; + user-select: none; +} +.auth-exact-page .agree input { + margin-top: 3px; + width: 13px; + height: 13px; + accent-color: var(--heat); + flex-shrink: 0; +} +.auth-exact-page .agree a { color: var(--heat); text-decoration: none; } +.auth-exact-page .agree a:hover { text-decoration: underline; } +.auth-exact-page .agree.has-error a { color: var(--accent-crimson); } + +/* 注册:CTA / 提示在表单内自然流(不借用 .auth-submit-zone 的绝对定位) */ +.auth-exact-page.auth-register-page .btn-cta { margin-top: 4px; } +.auth-exact-page.auth-register-page .form-error, +.auth-exact-page.auth-register-page .login-progress { + position: static; + margin: 0 0 12px; +} + +/* ── 登录态:加了 OR / 第三方 / 切换行后内容变高,改自然流让卡片随内容长高 ── */ +.auth-exact-page.auth-login-page .auth-wrap { + height: auto; + min-height: 560px; + align-items: stretch; +} +.auth-exact-page.auth-login-page .auth-brand { + flex: 0 0 calc(100% - 420px); + width: calc(100% - 420px); +} +.auth-exact-page.auth-login-page .auth-form { + position: static; + width: 420px; + flex: 0 0 420px; + height: auto; + min-height: 0; + align-self: stretch; +} +.auth-exact-page.auth-login-page .auth-form form { + flex: 0 0 auto; + margin-top: 24px; +} +.auth-exact-page.auth-login-page .auth-submit-zone { + position: static; + width: auto; + margin: 0; + padding-top: 0; +} +.auth-exact-page.auth-login-page .form-error, +.auth-exact-page.auth-login-page .login-progress { + position: static; + margin: 0 0 12px; +} + @media (max-width: 820px) { .auth-exact-page .auth-wrap { flex-direction: column; @@ -1097,6 +1304,13 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); } width: auto; margin-top: auto; } + .auth-exact-page.auth-register-page .auth-brand, + .auth-exact-page.auth-register-page .auth-form { + flex: 0 0 auto; + width: auto; + } + .auth-exact-page.auth-register-page .val-list { display: none; } + .auth-exact-page.auth-register-page .auth-form { padding: 32px 28px; } } /* Dashboard 区类(.dash-grid/.dash-side/.recent-row/.shortcut/.tip)已迁入 diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 6e86a70..400ab74 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -20,6 +20,21 @@ export type TeamMember = { user: User; }; +export type Invitation = { + id: string; + code: string; + role: string; + email: string; + monthly_credit_limit: string; + status: "pending" | "used" | "revoked" | "expired"; + expires_at: string; + used_by: string | null; + used_by_username: string | null; + used_at: string | null; + created_at: string; + register_url: string; +}; + export type AuthPayload = { token: string; user: User; diff --git a/core/qa/visual-parity/_wave35-auth.mjs b/core/qa/visual-parity/_wave35-auth.mjs new file mode 100644 index 0000000..62ca5c1 --- /dev/null +++ b/core/qa/visual-parity/_wave35-auth.mjs @@ -0,0 +1,83 @@ +// Wave 3.5 auth walkthrough (scratch): 登录(秒进/无邮箱/第三方/切换) + 注册(无邮箱/5字段) + 切换态. +import { chromium } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; + +const BASE = process.env.BASE || "http://localhost:5173"; +const OUT = path.resolve("../../../_qa_shots/wave35"); +fs.mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch({ headless: true }); +const r = { login: {}, register: {}, switch: {}, consoleErrors: [] }; + +// 登录态(无 token,真在登录页) +{ + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); + p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("login:" + m.text()); }); + p.on("pageerror", (e) => r.consoleErrors.push("login:PAGEERR:" + e.message)); + await p.goto(BASE + "/login", { waitUntil: "load" }); + await p.waitForTimeout(500); + r.login = { + usernameField: await p.locator("#auth-username").count(), + emailField: await p.locator("input[type=email]").count(), + ssoButtons: await p.locator(".sso-btn").count(), + switchRow: await p.locator(".switch-row").count(), + }; + await p.screenshot({ path: path.join(OUT, "login.png") }); + // 登录秒进(demo 账号) + await p.fill("#auth-username", "airshelf"); + await p.fill("#auth-pwd", "Restraint2026"); + const t0 = Date.now(); + await p.click("button.btn-cta"); + try { + await p.waitForFunction(() => !location.pathname.startsWith("/login"), { timeout: 12000 }); + r.login.loginMs = Date.now() - t0; + } catch { r.login.loginMs = -1; } + await ctx.close(); +} + +// 注册态 +{ + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); + p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("reg:" + m.text()); }); + p.on("pageerror", (e) => r.consoleErrors.push("reg:PAGEERR:" + e.message)); + await p.goto(BASE + "/register", { waitUntil: "load" }); + await p.waitForTimeout(600); + const bodyText = await p.locator("body").innerText().catch(() => ""); + r.register = { + emailField: await p.locator("input[type=email]").count(), + textInputs: await p.locator(".auth-form input[type=text], .auth-form input[type=password]").count(), + hasTeamLabel: /团队名|团队名称/.test(bodyText), + hasInvite: /邀请码/.test(bodyText), + hasEmailText: /邮箱/.test(bodyText), + ctaButton: await p.locator(".btn-cta").count(), + switchRow: await p.locator(".switch-row").count(), + }; + await p.screenshot({ path: path.join(OUT, "register.png") }); + await ctx.close(); +} + +// 切换:login → 点切换行 → register +{ + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); + await p.goto(BASE + "/login", { waitUntil: "load" }); + await p.waitForTimeout(500); + const link = p.locator(".switch-row a, .switch-row button").first(); + if (await link.count()) { + await link.click(); + await p.waitForTimeout(400); + r.switch = { + urlNowRegister: p.url().includes("/register"), + registerFormShown: /邀请码/.test(await p.locator("body").innerText().catch(() => "")), + }; + await p.screenshot({ path: path.join(OUT, "switched-to-register.png") }); + } else r.switch = "no switch link"; + await ctx.close(); +} + +await browser.close(); +console.log(JSON.stringify(r, null, 2)); +fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));