LoginPage.tsx 7.6 KB
// REQ-USR-004: 登录页(复刻 prototype #screen-login 三段式布局 + 真实对接后端)
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Form, Input, Select, App as AntdApp, type InputRef } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '../../../store/hooks';
import { setCredentials } from '../../../store/slices/authSlice';
import { login, fetchCompanies } from '../../../api/usrApi';
import type { CompanyOption, LoginPayload } from '../../../api/types';
import { ApiError } from '../../../api/request';
import {
  LOGIN_SUCCESS_TEXT,
  resolveLoginErrorText,
  CLEAR_PASSWORD_CODES,
} from './loginMessages';
import styles from './Login.module.css';

interface LoginFormValues {
  sUserName: string;
  password: string;
  companyId: number;
}

// 版本下拉 label 规则(D8):有 sVersion → 「公司名(版本)」,否则仅公司名;value 恒取 id
function toOption(c: CompanyOption) {
  return {
    value: c.id,
    label: c.sVersion ? `${c.sCompanyName}(${c.sVersion})` : c.sCompanyName,
  };
}

export default function LoginPage() {
  const [form] = Form.useForm<LoginFormValues>();
  const dispatch = useAppDispatch();
  const navigate = useNavigate();
  const { message } = AntdApp.useApp();
  const passwordRef = useRef<InputRef>(null);

  const [companies, setCompanies] = useState<CompanyOption[]>([]);
  const [companiesLoading, setCompaniesLoading] = useState(false);
  const [companiesError, setCompaniesError] = useState(false);
  const [submitting, setSubmitting] = useState(false);

  const loadCompanies = useCallback(async () => {
    setCompaniesLoading(true);
    setCompaniesError(false);
    try {
      const list = await fetchCompanies();
      setCompanies(list);
      // 仅一项时默认选中(spec § 6.3)
      if (list.length === 1) {
        form.setFieldValue('companyId', list[0].id);
      }
    } catch {
      setCompaniesError(true);
      setCompanies([]);
      message.error('版本加载失败');
    } finally {
      setCompaniesLoading(false);
    }
  }, [form, message]);

  // 页面挂载即预加载版本下拉(spec § 3 companiesLoading)
  useEffect(() => {
    void loadCompanies();
  }, [loadCompanies]);

  const options = useMemo(() => companies.map(toOption), [companies]);
  const isEmpty = !companiesLoading && !companiesError && companies.length === 0;

  const handleFinish = async (values: LoginFormValues) => {
    if (submitting) return; // 防重复提交(BR10)
    setSubmitting(true);
    const payload: LoginPayload = {
      sUserName: values.sUserName,
      password: values.password,
      companyId: values.companyId,
    };
    try {
      const { token, user } = await login(payload);
      dispatch(setCredentials({ token, user }));
      message.success(LOGIN_SUCCESS_TEXT);
      navigate('/', { replace: true });
    } catch (err) {
      const code = err instanceof ApiError ? err.code : -1;
      message.error(resolveLoginErrorText(code));
      if (CLEAR_PASSWORD_CODES.has(code)) {
        form.setFieldValue('password', '');
        // 字段在 submitting 期间禁用,待 submitting 复位后再聚焦密码框(D5)
        setSubmitting(false);
        // 等待禁用状态解除后聚焦,避免聚焦到 disabled 输入失败
        setTimeout(() => passwordRef.current?.focus(), 0);
        return;
      }
    } finally {
      setSubmitting(false);
    }
  };

  const versionPlaceholder = companiesLoading ? '加载版本中…' : '请选择版本';

  return (
    <div className={styles.wrap}>
      <div className={styles.head}>
        <span className={styles.logo}>
          <svg viewBox="0 0 64 64" width="42" height="42" fill="#0e1216" aria-hidden="true">
            <path d="M14 10c2 4 1 8-1 11 3-1 7 0 10 3 1-4 4-7 8-7-3 3-4 7-3 11l4 1c-1 3 0 6 3 8-3 0-6 1-8 4-1-3-4-5-8-5 2-3 2-7 0-10-3 1-7 0-10-3 3 0 5-2 6-5l-1-8z" />
            <path d="M48 14c-2 3-2 6-1 9-2-2-5-2-8-1 1 3 1 6-1 9 3 0 5 2 6 5 1-3 4-5 7-5-2-3-2-6 0-9 2 1 5 1 7-1-2 0-4-1-5-3-1-2-3-4-5-4z" />
            <path d="M28 38c2 3 5 5 9 5 1 4 4 7 8 8-3 2-5 5-5 9-3-2-7-3-11-2 1-3 1-7-1-10-3 0-6-1-8-4 3-1 6-3 8-6z" />
          </svg>
        </span>
        <span className={styles.brandName}>Antler ERP</span>
        <span className={styles.brandSub}>欢迎登录EBC平台</span>
      </div>

      <div className={styles.hero}>
        <div className={styles.heroText}>
          <div className={styles.heroEn}>Enterprise Business Capability</div>
          <div className={styles.heroZh}>企业业务能力平台</div>
          <div className={styles.heroErp}>ERP</div>
        </div>

        <div className={styles.card}>
          <h3 className={styles.cardTitle}>用户登录</h3>
          <Form<LoginFormValues>
            form={form}
            onFinish={handleFinish}
            layout="vertical"
            requiredMark={false}
            disabled={submitting}
          >
            <Form.Item
              name="sUserName"
              rules={[{ required: true, message: '请输入用户名' }]}
            >
              <Input
                prefix={<UserOutlined />}
                placeholder="请输入你的用户名"
                size="large"
                autoComplete="username"
              />
            </Form.Item>

            <Form.Item
              name="password"
              rules={[{ required: true, message: '请输入密码' }]}
            >
              <Input.Password
                ref={passwordRef}
                prefix={<LockOutlined />}
                placeholder="请输入你的密码"
                size="large"
                autoComplete="current-password"
              />
            </Form.Item>

            <Form.Item
              name="companyId"
              rules={[{ required: true, message: '请选择版本' }]}
            >
              <Select
                placeholder={versionPlaceholder}
                size="large"
                loading={companiesLoading}
                disabled={companiesLoading}
                options={options}
                notFoundContent={isEmpty ? '暂无可用版本' : undefined}
              />
            </Form.Item>

            {isEmpty && (
              <div className={styles.emptyHint}>未获取到可登录版本,请联系管理员</div>
            )}

            {companiesError && (
              <div className={styles.retryBox}>
                <span>版本加载失败</span>
                <Button
                  type="link"
                  size="small"
                  disabled={false}
                  onClick={() => void loadCompanies()}
                >
                  点击重试
                </Button>
              </div>
            )}

            <Form.Item>
              <Button
                type="primary"
                htmlType="submit"
                block
                size="large"
                loading={submitting}
                className={styles.submitBtn}
              >
                登 录
              </Button>
            </Form.Item>
          </Form>
        </div>
      </div>

      <div className={styles.foot}>
        🛠 ©Copyright Antler Software | 印刷智慧工厂 | 印刷MES | 印刷ERP | 印刷电商平台 |
        文件智能处理 | 印前自动化 | 400-880-6237
        <span className={styles.footIcp}>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="#3a6cb6" aria-hidden="true">
            <path d="M12 2l9 4v6c0 5-4 9-9 10-5-1-9-5-9-10V6z" />
          </svg>
          沪ICP备14034791号-1
        </span>
      </div>
    </div>
  );
}