合伙人端登录验证调整还有日志落地

This commit is contained in:
2026-07-07 14:34:02 +08:00
parent af989c9d33
commit 2e61caa49c
23 changed files with 1018 additions and 47 deletions
+2
View File
@@ -28,6 +28,7 @@ import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
import StoreLogsPage from './pages/StoreLogsPage';
import PartnerLogsPage from './pages/PartnerLogsPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
@@ -67,6 +68,7 @@ export default function App() {
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/partners" element={<PartnerLogsPage />} />
<Route path="/logs/hq" element={<HqLogsPage />} />
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
@@ -74,6 +74,7 @@ const MENU_ITEMS: MenuProps['items'] = [
children: [
{ key: '/logs/users', label: '用户日志' },
{ key: '/logs/stores', label: '商户日志' },
{ key: '/logs/partners', label: '合伙人日志' },
{ key: '/logs/hq', label: 'HQ 操作日志' },
{ key: '/logs/third-party', label: '第三方日志' },
],
+6
View File
@@ -0,0 +1,6 @@
export {
PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory,
} from '@dukang/shared-types';
@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import {
PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory,
} from '../lib/partner-log';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
partnerId: string;
partnerAccountId: string | null;
accountName: string | null;
accountPhone: string | null;
companyName: string | null;
category: PartnerLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
};
function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—';
const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
}
export default function PartnerLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [category, setCategory] = useState(searchParams.get('category') ?? '');
const [filters, setFilters] = useState<Record<string, string>>(() => ({
partnerId: searchParams.get('partnerId') ?? '',
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
phone: searchParams.get('phone') ?? '',
companyName: searchParams.get('companyName') ?? '',
eventName: searchParams.get('eventName') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/logs/partners',
() => {
const qs = new URLSearchParams();
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.eventName) qs.set('eventName', filters.eventName);
if (category) qs.set('category', category);
return qs;
},
[filters, category],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '合伙人',
width: 180,
render: (_, r) => (
<div>
<div>{r.companyName || '—'}</div>
<div style={{ color: '#999', fontSize: 12 }}>{r.partnerId}</div>
</div>
),
},
{
title: '账号',
width: 150,
render: (_, r) => (
<div>
<div>{r.accountName || '—'}</div>
<div style={{ color: '#999', fontSize: 12 }}>{r.accountPhone || r.partnerAccountId || '—'}</div>
</div>
),
},
{
title: '分类',
dataIndex: 'category',
width: 100,
render: (v: PartnerLogCategory | null, r) => (
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 180 },
{
title: '关联',
width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要',
ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
</Typography.Title>
<Segmented
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category}
onChange={(v) => {
setCategory(String(v));
setPage(1);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (v) next.set('category', String(v));
else next.delete('category');
return next;
});
}}
style={{ marginBottom: 16 }}
/>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="companyName" label="公司">
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="partnerId" label="合伙人ID">
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="eventName" label="事件名">
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
form.resetFields();
setFilters({
partnerId: '',
partnerAccountId: '',
phone: '',
companyName: '',
eventName: '',
});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 1100 }}
/>
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && (
<Descriptions column={1} bordered size="small">
{Object.entries(detail).map(([k, v]) => (
<Descriptions.Item key={k} label={k}>
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
</Descriptions.Item>
))}
</Descriptions>
)}
</Drawer>
</div>
);
}
+123 -38
View File
@@ -23,12 +23,26 @@ function loadRememberedPhone(): { phone: string; remember: boolean } {
}
}
function formatPartnerError(e: unknown): string {
const text = e instanceof Error ? e.message : '操作失败';
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
return '未找到合伙人账号';
}
return text;
}
export default function LoginPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const remembered = loadRememberedPhone();
const [step, setStep] = useState<'phone' | 'code'>('phone');
const [phone, setPhone] = useState(remembered.phone || '13700000001');
const [confirmedProfile, setConfirmedProfile] = useState<{
maskedPhone: string;
name?: string;
companyName?: string;
} | null>(null);
const [code, setCode] = useState('123456');
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true);
@@ -77,6 +91,37 @@ export default function LoginPage() {
}
}
function backToPhoneStep() {
setStep('phone');
setConfirmedProfile(null);
setCode('');
setMsg('');
setCodeCooldown(0);
}
async function confirmPhone() {
if (!ensureAgreed()) return;
setLoading(true);
setMsg('');
try {
const data = await request<{ ok: boolean; maskedPhone: string; name?: string; companyName?: string }>(
'PARTNER_H5',
'/partner/auth/phone/check',
{ method: 'POST', body: JSON.stringify({ phone }) },
);
setConfirmedProfile({
maskedPhone: data.maskedPhone,
name: data.name,
companyName: data.companyName,
});
setStep('code');
} catch (e) {
setMsg(formatPartnerError(e));
} finally {
setLoading(false);
}
}
async function sendCode() {
if (!ensureAgreed()) return;
setMsg('');
@@ -97,7 +142,7 @@ export default function LoginPage() {
});
}, 1000);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
setMsg(formatPartnerError(e));
}
}
@@ -106,10 +151,6 @@ export default function LoginPage() {
setLoading(true);
setMsg('');
try {
await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
});
const data = await request<{ accessToken: string }>('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone, code }),
@@ -123,7 +164,7 @@ export default function LoginPage() {
}
navigate('/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
setMsg(formatPartnerError(e));
} finally {
setLoading(false);
}
@@ -203,39 +244,83 @@ export default function LoginPage() {
<main className="partner-auth-card">
<h2 className="partner-auth-card-title"></h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-input-wrap">
<span className="material-symbols-outlined partner-input-icon">smartphone</span>
<input className="partner-input" type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="partner-input-row">
<div className="partner-input-wrap">
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input className="partner-input" type="text" placeholder="验证码" value={code} onChange={(e) => setCode(e.target.value)} />
</div>
<button type="button" className="partner-code-btn" onClick={sendCode} disabled={codeCooldown > 0}>
{codeCooldown > 0 ? `${codeCooldown}s 后重发` : '获取验证码'}
</button>
</div>
<label className="partner-remember-row">
<input
type="checkbox"
checked={rememberAccount}
onChange={(e) => setRememberAccount(e.target.checked)}
/>
<span></span>
</label>
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
{step === 'phone' ? (
<>
<div className="partner-input-wrap">
<span className="material-symbols-outlined partner-input-icon">smartphone</span>
<input
className="partner-input"
type="tel"
placeholder="请输入手机号"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
setMsg('');
}}
/>
</div>
<button type="button" className="partner-btn-primary" onClick={confirmPhone} disabled={loading}>
<span>{loading ? '校验中...' : '下一步'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
</>
) : (
<>
<div className="partner-glass-card" style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div>
<div style={{ fontWeight: 600 }}>
{confirmedProfile?.name || '合伙人账号'}
{confirmedProfile?.companyName ? (
<span className="text-muted body-md" style={{ fontWeight: 400, marginLeft: 8 }}>
{confirmedProfile.companyName}
</span>
) : null}
</div>
<div className="text-muted body-md" style={{ marginTop: 4 }}>
{confirmedProfile?.maskedPhone || phone}
</div>
</div>
<button type="button" className="partner-link" onClick={backToPhoneStep} style={{ flexShrink: 0 }}>
</button>
</div>
</div>
<div className="partner-input-row">
<div className="partner-input-wrap">
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input className="partner-input" type="text" placeholder="验证码" value={code} onChange={(e) => setCode(e.target.value)} />
</div>
<button type="button" className="partner-code-btn" onClick={sendCode} disabled={codeCooldown > 0}>
{codeCooldown > 0 ? `${codeCooldown}s 后重发` : '获取验证码'}
</button>
</div>
<label className="partner-remember-row">
<input
type="checkbox"
checked={rememberAccount}
onChange={(e) => setRememberAccount(e.target.checked)}
/>
<span></span>
</label>
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
</>
)}
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
<div className="partner-auth-divider"><span></span></div>
<button type="button" className="partner-btn-wechat" onClick={wechatLogin} disabled={wxLoading}>
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
</svg>
<span>{wxLoading ? '登录中...' : '微信一键授权'}</span>
</button>
{step === 'phone' && (
<>
<div className="partner-auth-divider"><span></span></div>
<button type="button" className="partner-btn-wechat" onClick={wechatLogin} disabled={wxLoading}>
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
</svg>
<span>{wxLoading ? '登录中...' : '微信一键授权'}</span>
</button>
</>
)}
<label className="partner-checkbox-row">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span>