Bladeren bron

merge: dashboard workbench minimal entry

ChenYu 4 maanden geleden
bovenliggende
commit
3a9e17a0b3

+ 11 - 0
backend/src/modules/auth/auth.service.spec.ts

@@ -72,6 +72,17 @@ describe('AuthService', () => {
       name: 'Ogawa',
       email: 'stylist@example.com',
     });
+
+    expect(jwtService.signAsync).toHaveBeenCalledWith(
+      {
+        sub: 1,
+        email: 'stylist@example.com',
+        username: 'Ogawa',
+      },
+      expect.objectContaining({
+        expiresIn: '7d',
+      }),
+    );
   });
 
   it('throws AUTH_INVALID_CREDENTIALS when the password does not match', async () => {

+ 1 - 0
backend/src/modules/auth/auth.service.ts

@@ -42,6 +42,7 @@ export class AuthService {
       {
         sub: Number(stylist.id),
         email: stylist.email,
+        username: stylist.username,
       },
       {
         expiresIn:

+ 22 - 0
backend/test/auth.e2e-spec.ts

@@ -39,10 +39,32 @@ describe('Auth (e2e)', () => {
     });
   });
 
+  it('/api/v1/auth/stylist/login (POST) returns a token payload with username', async () => {
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/auth/stylist/login')
+      .send({
+        email: 'stylist@example.com',
+        password: 'Passw0rd!',
+        remember: false,
+      })
+      .expect(201);
+
+    const payload = await jwtService.verifyAsync<{
+      sub: number;
+      email: string;
+      username: string;
+    }>(response.body.data.token);
+
+    expect(payload.sub).toBe(1);
+    expect(payload.email).toBe('stylist@example.com');
+    expect(payload.username).toBe('Ogawa');
+  });
+
   it('/api/v1/customers (GET) accepts a valid signed token', async () => {
     const token = await jwtService.signAsync({
       sub: 1,
       email: 'stylist@example.com',
+      username: 'Ogawa',
     });
 
     const response = await request(app.getHttpServer())

+ 2 - 1
docs/superpowers/specs/2026-06-28-stylist-login-minimal-closed-loop-design.md

@@ -170,7 +170,8 @@
 
 - 本 case 只签发单个 access token。
 - token 类型采用 JWT。
-- payload 只包含最小必要身份字段,例如 `sub` 和 `email`。
+- payload 只包含最小必要身份字段,例如 `sub`、`email`、`username`。
+- `username` 供工作台欢迎区直接消费,不再额外请求 `/me` 接口。
 - 不在 token 中塞入模板数量、客户统计等业务数据,避免 token 承担缓存职责。
 
 ### Remember 语义

+ 6 - 0
frontend/src/i18n/locales/ja/translation.json

@@ -196,6 +196,12 @@
       "weeklyRecords": "今週の記録",
       "templates": "テンプレート"
     },
+    "undeveloped": {
+      "statValue": "--",
+      "badge": "このバージョンでは未対応",
+      "body": "このバージョンではワークベンチ入口のみ提供し、後続で集約データを接続します。",
+      "disabledAction": "このバージョンでは未対応"
+    },
     "panels": {
       "today": { "badge": "本日の予約", "title": "本日の顧客", "action": "一覧を見る", "start": "開始", "detail": "詳細" },
       "followUp": { "badge": "フォロー", "title": "フォロー対象顧客", "action": "管理" },

+ 6 - 0
frontend/src/i18n/locales/zh/translation.json

@@ -196,6 +196,12 @@
       "weeklyRecords": "本周记录",
       "templates": "发型模板"
     },
+    "undeveloped": {
+      "statValue": "--",
+      "badge": "当前版本暂未开发",
+      "body": "当前版本仅保留工作台入口,后续将从工作台聚合数据恢复该区域。",
+      "disabledAction": "当前版本暂未开发"
+    },
     "panels": {
       "today": { "badge": "今日预约", "title": "今日顾客", "action": "查看列表", "start": "开始", "detail": "详情" },
       "followUp": { "badge": "跟进", "title": "待跟进顾客", "action": "管理" },

+ 45 - 111
frontend/src/pages/dashboard/DashboardPage.tsx

@@ -1,36 +1,10 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useMemo } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
-import { getCustomerDashboardSummary } from '../../services/api/customers'
-import { getTemplateDashboardSummary } from '../../services/api/templates'
-import { getStylistMe } from '../../services/api/stylist'
-import { getWorkbenchData } from '../../services/api/workbench'
-import type { CustomerDashboardSummary, TemplateDashboardSummary } from '../../services/api/contracts'
-import type { WorkbenchEntity } from '../../services/api/contracts'
+import { getSessionIdentity } from '../../services/http/session'
 import { usePageContext } from '../../shared/pageContext'
 import { DashboardLayout } from '../../layouts/DashboardLayout'
 
-const emptyWorkbench: WorkbenchEntity = {
-  appointments: [],
-  followUps: [],
-  records: [],
-}
-
-const emptyCustomerSummary: CustomerDashboardSummary = {
-  total: 0,
-  today: 0,
-  followUp: 0,
-  simulated: 0,
-  weeklyRecords: 0,
-}
-
-const emptyTemplateSummary: TemplateDashboardSummary = {
-  total: 0,
-  published: 0,
-  weeklyUsed: 0,
-  draft: 0,
-}
-
 function greetingForHour(hour: number, locale: string): string {
   if (locale === 'ja') {
     if (hour < 12) return 'おはようございます'
@@ -44,18 +18,21 @@ function greetingForHour(hour: number, locale: string): string {
 
 export function DashboardPage() {
   const { locale, path, copy } = usePageContext()
-  const [dashboardData, setDashboardData] = useState<WorkbenchEntity>(emptyWorkbench)
-  const [customerSummary, setCustomerSummary] = useState<CustomerDashboardSummary>(emptyCustomerSummary)
-  const [templateSummary, setTemplateSummary] = useState<TemplateDashboardSummary>(emptyTemplateSummary)
-  const [stylistName, setStylistName] = useState('')
   const pageCopy = copy.dashboardPage
-
-  useEffect(() => {
-    getWorkbenchData().then(setDashboardData).catch(() => setDashboardData(emptyWorkbench))
-    getCustomerDashboardSummary().then(setCustomerSummary).catch(() => setCustomerSummary(emptyCustomerSummary))
-    getTemplateDashboardSummary().then(setTemplateSummary).catch(() => setTemplateSummary(emptyTemplateSummary))
-    getStylistMe().then((me) => setStylistName(me.name || me.displayName)).catch(() => setStylistName(''))
-  }, [])
+  const sessionIdentity = useMemo(() => getSessionIdentity(), [])
+  const stylistName = sessionIdentity.username || pageCopy.greetingName
+  const undevelopedCopy = pageCopy.undeveloped
+  const undevelopedStats = [
+    pageCopy.stats.todayCustomers,
+    pageCopy.stats.followUp,
+    pageCopy.stats.weeklyRecords,
+    pageCopy.stats.templates,
+  ]
+  const undevelopedPanels = [
+    { layoutClassName: 'col-xl-7', ...pageCopy.panels.today },
+    { layoutClassName: 'col-xl-5', ...pageCopy.panels.followUp },
+    { layoutClassName: 'col-12', ...pageCopy.panels.records },
+  ]
 
   const now = useMemo(() => new Date(), [])
   const dateLabel = useMemo(
@@ -68,9 +45,6 @@ export function DashboardPage() {
     [locale, now],
   )
   const greeting = greetingForHour(now.getHours(), locale)
-  const headlineStat = locale === 'ja'
-    ? `本日の予約 ${customerSummary.today} 件 / 未対応フォロー ${customerSummary.followUp} 件`
-    : `今日预约 ${customerSummary.today} 件 / 待跟进 ${customerSummary.followUp} 件`
 
   return (
     <DashboardLayout>
@@ -84,12 +58,12 @@ export function DashboardPage() {
           <div className="row align-items-end justify-content-between mb-4">
             <div className="col-lg-10">
               <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
-              <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{greeting}<br />{stylistName || pageCopy.greetingName}</h1>
+              <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{greeting}<br />{stylistName}</h1>
               <p className="fs-18 lh-30 mb-0">{pageCopy.summary}</p>
             </div>
             <div className="col-lg-4 text-lg-end mt-3 mt-lg-0">
               <span className="d-block fs-14 text-medium-gray">{dateLabel}</span>
-              <span className="fw-700 text-dark-gray">{headlineStat}</span>
+              {/* 当前版本未开发欢迎区统计,后续从 workbench 聚合接口恢复预约与跟进真实数据。 */}
             </div>
           </div>
 
@@ -118,10 +92,16 @@ export function DashboardPage() {
           </div>
 
           <div className="row row-cols-2 row-cols-lg-4 row-cols-md-4 g-3 mb-5">
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{customerSummary.today}</span><span className="fs-14">{pageCopy.stats.todayCustomers}</span></div></div>
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{customerSummary.followUp}</span><span className="fs-14">{pageCopy.stats.followUp}</span></div></div>
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{customerSummary.weeklyRecords}</span><span className="fs-14">{pageCopy.stats.weeklyRecords}</span></div></div>
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{templateSummary.total}</span><span className="fs-14">{pageCopy.stats.templates}</span></div></div>
+            {/* 当前版本未开发统计卡,后续从顾客汇总、模板汇总和工作台聚合接口恢复真实数据。 */}
+            {undevelopedStats.map((label) => (
+              <div key={label} className="col">
+                <div className="workbench-stat bg-white border-radius-8px p-20px opacity-75">
+                  <span className="d-block fs-26 fw-800 text-dark-gray">{undevelopedCopy.statValue}</span>
+                  <span className="fs-14 d-block">{label}</span>
+                  <span className="fs-13 text-medium-gray d-block mt-2">{undevelopedCopy.badge}</span>
+                </div>
+              </div>
+            ))}
           </div>
         </div>
       </section>
@@ -129,73 +109,27 @@ export function DashboardPage() {
       <section className="pt-0">
         <div className="container">
           <div className="row g-4">
-            <div className="col-xl-7">
-              <div id="today" className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
-                <div className="d-flex align-items-center justify-content-between mb-25px">
-                  <div>
-                    <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.panels.today.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.panels.today.title}</h2>
-                  </div>
-                  <Link to={path('/customers')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.panels.today.action}</Link>
-                </div>
-                <div className="workbench-list">
-                  {dashboardData.appointments.length === 0 ? (
-                    <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p>
-                  ) : dashboardData.appointments.map((item, index) => (
-                    <div key={`${item.time}-${item.customer}`} className="workbench-list-item">
-                      <div><span className="fw-700 text-dark-gray d-block">{item.time} {item.customer}</span><span className="fs-14 text-medium-gray">{item.note}</span></div>
-                      <Link to={path('/simulation')} className={`btn btn-very-small btn-rounded ${index === 0 ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`}>{index === 0 ? pageCopy.panels.today.start : pageCopy.panels.today.detail}</Link>
-                    </div>
-                  ))}
-                </div>
-              </div>
-            </div>
-
-            <div className="col-xl-5">
-              <div id="follow-up" className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
-                <div className="d-flex align-items-center justify-content-between mb-25px">
-                  <div>
-                    <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.panels.followUp.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.panels.followUp.title}</h2>
-                  </div>
-                  <Link to={path('/customers')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.panels.followUp.action}</Link>
-                </div>
-                <div className="workbench-list compact">
-                  {dashboardData.followUps.length === 0 ? (
-                    <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p>
-                  ) : dashboardData.followUps.map((item) => (
-                    <div key={`${item.customer}-${item.reason}`} className="workbench-list-item">
-                      <div><span className="fw-700 text-dark-gray d-block">{item.customer}</span><span className="fs-14 text-medium-gray">{item.reason}</span></div>
-                      <span className="workbench-badge">{item.badge}</span>
+            {/* 当前版本未开发工作台列表区块,后续从 workbench 聚合接口恢复今日顾客、待跟进顾客和最近施术记录。 */}
+            {undevelopedPanels.map((panel) => (
+              <div key={panel.title} className={panel.layoutClassName}>
+                <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100 opacity-75">
+                  <div className="d-flex align-items-center justify-content-between mb-25px">
+                    <div>
+                      <span className="fs-14 fw-700 text-base-color text-uppercase">{panel.badge}</span>
+                      <h2 className="fw-700 text-dark-gray fs-26 mb-0">{panel.title}</h2>
                     </div>
-                  ))}
-                </div>
-              </div>
-            </div>
-
-            <div className="col-12">
-              <div id="records" className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-                <div className="d-flex align-items-center justify-content-between mb-25px">
-                  <div>
-                    <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.panels.records.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.panels.records.title}</h2>
-                  </div>
-                  <Link to={path('/customers')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.panels.records.action}</Link>
-                </div>
-                <div className="workbench-record-table">
-                  <div className="workbench-record-row workbench-record-head">
-                    {pageCopy.panels.records.headers.map((label: string) => <span key={label}>{label}</span>)}
+                    <button
+                      type="button"
+                      className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+                      disabled
+                    >
+                      {undevelopedCopy.disabledAction}
+                    </button>
                   </div>
-                  {dashboardData.records.length === 0 ? (
-                    <p className="fs-15 text-medium-gray mb-0 mt-3">{copy.common.empty}</p>
-                  ) : dashboardData.records.map((item) => (
-                    <div key={`${item.date}-${item.customer}`} className="workbench-record-row">
-                      <span>{item.date}</span><span>{item.customer}</span><span>{item.menu}</span><span>{item.template || '—'}</span><span>{item.follow || '—'}</span>
-                    </div>
-                  ))}
+                  <p className="fs-15 text-medium-gray mb-0">{undevelopedCopy.body}</p>
                 </div>
               </div>
-            </div>
+            ))}
           </div>
         </div>
       </section>

+ 96 - 0
frontend/src/pages/dashboard/__tests__/DashboardPage.test.tsx

@@ -0,0 +1,96 @@
+import { HelmetProvider } from 'react-helmet-async'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import '../../../i18n'
+import { setSession } from '../../../services/http/session'
+import { DashboardPage } from '../DashboardPage'
+
+function createToken(payload: Record<string, unknown>) {
+  const encoded = btoa(JSON.stringify(payload))
+    .replace(/\+/g, '-')
+    .replace(/\//g, '_')
+    .replace(/=+$/g, '')
+  return `header.${encoded}.signature`
+}
+
+describe('DashboardPage', () => {
+  it('shows the username from the login token in the welcome header', async () => {
+    setSession(
+      createToken({
+        sub: 1,
+        email: 'stylist@example.com',
+        username: 'Ogawa',
+      }),
+      'Authorization',
+    )
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/dashboard']}>
+          <Routes>
+            <Route path="/:locale/dashboard" element={<DashboardPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByRole('heading', { name: /Ogawa/i })).toBeInTheDocument()
+  })
+
+  it('keeps only three real entry links and hides the welcome stats line', () => {
+    setSession(
+      createToken({
+        sub: 1,
+        email: 'stylist@example.com',
+        username: 'Ogawa',
+      }),
+      'Authorization',
+    )
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/dashboard']}>
+          <Routes>
+            <Route path="/:locale/dashboard" element={<DashboardPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(screen.queryByText(/今日预约 .* 件 \/ 待跟进 .* 件/i)).not.toBeInTheDocument()
+
+    const entryLinks = screen.getAllByRole('link').filter((element) => {
+      const href = element.getAttribute('href') || ''
+      const className = element.getAttribute('class') || ''
+      return className.includes('workbench-action-card')
+        && (href === '/zh/customers/new' || href === '/zh/simulation' || href === '/zh/templates')
+    })
+
+    expect(entryLinks).toHaveLength(3)
+  })
+
+  it('renders undeveloped placeholders for stats and workbench panels', () => {
+    setSession(
+      createToken({
+        sub: 1,
+        email: 'stylist@example.com',
+        username: 'Ogawa',
+      }),
+      'Authorization',
+    )
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/dashboard']}>
+          <Routes>
+            <Route path="/:locale/dashboard" element={<DashboardPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(screen.getAllByText('当前版本暂未开发').length).toBeGreaterThanOrEqual(4)
+    expect(screen.getAllByRole('button', { name: '当前版本暂未开发' }).length).toBeGreaterThanOrEqual(3)
+  })
+})

+ 36 - 0
frontend/src/services/http/__tests__/session.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import { clearSession, getSessionIdentity, setSession } from '../session'
+
+function createToken(payload: Record<string, unknown>) {
+  const encoded = btoa(JSON.stringify(payload))
+    .replace(/\+/g, '-')
+    .replace(/\//g, '_')
+    .replace(/=+$/g, '')
+  return `header.${encoded}.signature`
+}
+
+describe('session identity', () => {
+  it('reads username from the current jwt payload', () => {
+    setSession(
+      createToken({
+        sub: 1,
+        email: 'stylist@example.com',
+        username: 'Ogawa',
+      }),
+      'Authorization',
+    )
+
+    expect(getSessionIdentity()).toEqual({
+      username: 'Ogawa',
+    })
+  })
+
+  it('returns nulls for malformed tokens', () => {
+    setSession('not-a-jwt', 'Authorization')
+
+    expect(getSessionIdentity()).toEqual({
+      username: null,
+    })
+    clearSession()
+  })
+})

+ 31 - 0
frontend/src/services/http/session.ts

@@ -1,6 +1,10 @@
 const TOKEN_KEY = 'aitori.token'
 const TOKEN_NAME_KEY = 'aitori.tokenName'
 
+type SessionIdentity = {
+  username: string | null
+}
+
 export function getToken(): string | null {
   try {
     return localStorage.getItem(TOKEN_KEY)
@@ -38,3 +42,30 @@ export function clearSession() {
 export function isAuthenticated(): boolean {
   return !!getToken()
 }
+
+function decodeJwtSegment(segment: string): Record<string, unknown> | null {
+  try {
+    const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
+    const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
+    return JSON.parse(atob(padded)) as Record<string, unknown>
+  } catch {
+    return null
+  }
+}
+
+export function getSessionIdentity(): SessionIdentity {
+  const token = getToken()
+  if (!token) {
+    return { username: null }
+  }
+
+  const [, payload] = token.split('.')
+  if (!payload) {
+    return { username: null }
+  }
+
+  const decoded = decodeJwtSegment(payload)
+  return {
+    username: typeof decoded?.username === 'string' ? decoded.username : null,
+  }
+}