Merge #20 into dev from dev_jacy
管理后台左侧列表调整 * dev_jacy: (3 commits) webadmin去掉新建门店的短信验证 web端增加微信绑定管理 管理后台左侧列表调整 Signed-off-by: jacy <moonjie444@163.com> Reviewed-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/20
This commit is contained in:
@@ -29,6 +29,8 @@ import HqLogsPage from './pages/HqLogsPage';
|
|||||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||||
import StoreLogsPage from './pages/StoreLogsPage';
|
import StoreLogsPage from './pages/StoreLogsPage';
|
||||||
import PartnerLogsPage from './pages/PartnerLogsPage';
|
import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||||
|
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||||
|
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
if (!getToken()) return <Navigate to="/login" replace />;
|
if (!getToken()) return <Navigate to="/login" replace />;
|
||||||
@@ -48,6 +50,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||||
@@ -73,6 +76,7 @@ export default function App() {
|
|||||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||||
|
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
|
LockOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
@@ -23,6 +24,7 @@ const { Header, Sider, Content } = Layout;
|
|||||||
const MENU_ITEMS: MenuProps['items'] = [
|
const MENU_ITEMS: MenuProps['items'] = [
|
||||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||||
|
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
||||||
{
|
{
|
||||||
key: 'products-group',
|
key: 'products-group',
|
||||||
icon: <ShoppingOutlined />,
|
icon: <ShoppingOutlined />,
|
||||||
@@ -33,7 +35,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
|
||||||
{
|
{
|
||||||
key: 'stores-group',
|
key: 'stores-group',
|
||||||
icon: <ShopOutlined />,
|
icon: <ShopOutlined />,
|
||||||
@@ -53,6 +54,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/partners', label: '开城合伙人' },
|
{ key: '/partners', label: '开城合伙人' },
|
||||||
{ key: '/cities', label: '开城城市' },
|
{ key: '/cities', label: '开城城市' },
|
||||||
{ key: '/partner-accounts', label: '开城合伙人账户' },
|
{ key: '/partner-accounts', label: '开城合伙人账户' },
|
||||||
|
{ key: '/partner-bills', label: '合伙人结算' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,8 +68,17 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/redeem/debug', label: '核销调试' },
|
{ key: '/redeem/debug', label: '核销调试' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
{
|
||||||
|
key: 'deliveries-group',
|
||||||
|
icon: <CarOutlined />,
|
||||||
|
label: '配送单',
|
||||||
|
children: [
|
||||||
|
{ key: '/deliveries', label: '配送单列表' },
|
||||||
|
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||||
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||||
{
|
{
|
||||||
key: 'logs-group',
|
key: 'logs-group',
|
||||||
icon: <FileTextOutlined />,
|
icon: <FileTextOutlined />,
|
||||||
@@ -80,15 +91,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/logs/third-party', label: '第三方日志' },
|
{ key: '/logs/third-party', label: '第三方日志' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
key: 'deliveries-group',
|
|
||||||
icon: <CarOutlined />,
|
|
||||||
label: '配送单',
|
|
||||||
children: [
|
|
||||||
{ key: '/deliveries', label: '配送单列表' },
|
|
||||||
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
|
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
|
||||||
{ value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' },
|
{ value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' },
|
||||||
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
||||||
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员/权限' },
|
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
|
||||||
|
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
|
||||||
{ value: 'USER_DELETE', label: '删除用户' },
|
{ value: 'USER_DELETE', label: '删除用户' },
|
||||||
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
|
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
|
||||||
{ value: 'ORDER_SHIP', label: '订单发货' },
|
{ value: 'ORDER_SHIP', label: '订单发货' },
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export type StoreCreateForm = {
|
|||||||
districtCode?: string;
|
districtCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
smsCode: string;
|
|
||||||
address: string;
|
address: string;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
@@ -23,7 +22,7 @@ const PHONE_RE = /^1\d{10}$/;
|
|||||||
const BANK_RE = /^\d{16,19}$/;
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
|
|
||||||
export function validateStoreCreateStep1(
|
export function validateStoreCreateStep1(
|
||||||
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'smsCode' | 'address' | 'intro'>,
|
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerId) return '请选择开城合伙人';
|
if (!form.partnerId) return '请选择开城合伙人';
|
||||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||||
@@ -31,7 +30,6 @@ export function validateStoreCreateStep1(
|
|||||||
if (!form.name?.trim()) return '请填写门店名称';
|
if (!form.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||||
if (!form.smsCode?.trim()) return '请填写短信验证码';
|
|
||||||
if (!form.address?.trim()) return '请填写详细地址';
|
if (!form.address?.trim()) return '请填写详细地址';
|
||||||
if (form.intro?.trim()) {
|
if (form.intro?.trim()) {
|
||||||
const len = form.intro.trim().length;
|
const len = form.intro.trim().length;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
@@ -8,7 +8,15 @@ import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string; phone: string; name: string; adminRole: string; status: string; lastLoginAt: string | null; createdAt: string;
|
id: string;
|
||||||
|
phone: string;
|
||||||
|
loginName: string | null;
|
||||||
|
hasPassword: boolean;
|
||||||
|
name: string;
|
||||||
|
adminRole: string;
|
||||||
|
status: string;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
@@ -38,6 +46,7 @@ export default function HqAccountsPage() {
|
|||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
|
||||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,7 +55,18 @@ export default function HqAccountsPage() {
|
|||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '姓名', dataIndex: 'name' },
|
{ title: '姓名', dataIndex: 'name' },
|
||||||
|
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||||
|
{
|
||||||
|
title: '登录方式',
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
{r.hasPassword ? <Tag color="blue">密码</Tag> : null}
|
||||||
|
<Tag>短信</Tag>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
|
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
||||||
@@ -55,7 +75,12 @@ export default function HqAccountsPage() {
|
|||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" disabled={!isSuperAdmin} onClick={() => {
|
<Button type="link" size="small" disabled={!isSuperAdmin} onClick={() => {
|
||||||
setDetail(row);
|
setDetail(row);
|
||||||
editForm.setFieldsValue(row);
|
editForm.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
loginName: row.loginName,
|
||||||
|
adminRole: row.adminRole,
|
||||||
|
status: row.status,
|
||||||
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}>编辑</Button>
|
}}>编辑</Button>
|
||||||
),
|
),
|
||||||
@@ -66,7 +91,18 @@ export default function HqAccountsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
||||||
{isSuperAdmin && <Button type="primary" onClick={() => setCreateOpen(true)}>新建账户</Button>}
|
{isSuperAdmin && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
createForm.resetFields();
|
||||||
|
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' });
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建账户
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||||
@@ -82,7 +118,9 @@ export default function HqAccountsPage() {
|
|||||||
<Button type="primary" onClick={async () => {
|
<Button type="primary" onClick={async () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const v = await editForm.validateFields();
|
const v = await editForm.validateFields();
|
||||||
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
const payload = { ...v };
|
||||||
|
if (!payload.password) delete payload.password;
|
||||||
|
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
void reload();
|
void reload();
|
||||||
@@ -90,6 +128,10 @@ export default function HqAccountsPage() {
|
|||||||
}>
|
}>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="loginName" label="用户名"><Input placeholder="用于密码登录" /></Form.Item>
|
||||||
|
<Form.Item name="password" label="新密码" extra="留空则不修改">
|
||||||
|
<Input.Password placeholder="至少 6 位" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -98,20 +140,47 @@ export default function HqAccountsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<Modal title="新建 HQ 账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
<Modal
|
||||||
const v = await createForm.validateFields();
|
title="新建 HQ 账户"
|
||||||
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
open={createOpen}
|
||||||
message.success('已创建');
|
onCancel={() => setCreateOpen(false)}
|
||||||
setCreateOpen(false);
|
onOk={async () => {
|
||||||
createForm.resetFields();
|
const v = await createForm.validateFields();
|
||||||
void reload();
|
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||||
}}>
|
message.success('已创建');
|
||||||
<Form form={createForm} layout="vertical">
|
setCreateOpen(false);
|
||||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
createForm.resetFields();
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}>
|
||||||
|
<Form.Item name="credentialType" label="创建方式">
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value="phone">手机号账户(短信登录)</Radio>
|
||||||
|
<Radio value="password">用户名密码账户</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色" initialValue="OPS">
|
<Form.Item name="adminRole" label="角色">
|
||||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{credentialType === 'phone' ? (
|
||||||
|
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
|
<Input maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Form.Item name="loginName" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input autoComplete="off" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '至少 6 位' }]}>
|
||||||
|
<Input.Password autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号(可选)" extra="不填则自动生成占位手机号,用于满足账号唯一约束">
|
||||||
|
<Input maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
HQ_ADMIN_ROLES,
|
||||||
|
HQ_PERMISSION_CATALOG,
|
||||||
|
type HqPermissionKey,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request, type HqProfile } from '../lib/api';
|
||||||
|
|
||||||
|
type RolePermRes = { role: string; permissionKeys: string[] };
|
||||||
|
type AccountOption = { id: string; name: string; phone: string; loginName: string | null; adminRole: string };
|
||||||
|
type AccountPermRes = {
|
||||||
|
account: AccountOption;
|
||||||
|
permissionKeys: string[];
|
||||||
|
rolePermissionKeys: string[];
|
||||||
|
userPermissionKeys: string[];
|
||||||
|
effectivePermissionKeys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||||||
|
|
||||||
|
function PermissionChecklist({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: string[];
|
||||||
|
onChange: (keys: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Checkbox.Group
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(checked) => onChange(checked as string[])}
|
||||||
|
>
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
{HQ_PERMISSION_CATALOG.map((item) => (
|
||||||
|
<Col key={item.key} span={8}>
|
||||||
|
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Checkbox.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HqPermissionsPage() {
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
|
const [role, setRole] = useState<string>('OPS');
|
||||||
|
const [roleKeys, setRoleKeys] = useState<string[]>([]);
|
||||||
|
const [roleLoading, setRoleLoading] = useState(false);
|
||||||
|
const [roleSaving, setRoleSaving] = useState(false);
|
||||||
|
|
||||||
|
const [accounts, setAccounts] = useState<AccountOption[]>([]);
|
||||||
|
const [accountId, setAccountId] = useState<string>();
|
||||||
|
const [accountKeys, setAccountKeys] = useState<string[]>([]);
|
||||||
|
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
|
||||||
|
const [accountLoading, setAccountLoading] = useState(false);
|
||||||
|
const [accountSaving, setAccountSaving] = useState(false);
|
||||||
|
|
||||||
|
const previewEffectiveKeys = useMemo(
|
||||||
|
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
||||||
|
[roleInheritedKeys, accountKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperAdmin) return;
|
||||||
|
request<{ items: AccountOption[] }>('/admin/hq-accounts?page=1&pageSize=100')
|
||||||
|
.then((res) => setAccounts(res.items))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperAdmin || !role) return;
|
||||||
|
setRoleLoading(true);
|
||||||
|
request<RolePermRes>(`/admin/hq-permissions/roles/${role}`)
|
||||||
|
.then((res) => setRoleKeys(res.permissionKeys))
|
||||||
|
.finally(() => setRoleLoading(false));
|
||||||
|
}, [isSuperAdmin, role]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperAdmin || !accountId) return;
|
||||||
|
setAccountLoading(true);
|
||||||
|
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
||||||
|
.then((res) => {
|
||||||
|
setAccountKeys(res.userPermissionKeys);
|
||||||
|
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||||
|
})
|
||||||
|
.finally(() => setAccountLoading(false));
|
||||||
|
}, [isSuperAdmin, accountId]);
|
||||||
|
|
||||||
|
async function saveRolePermissions() {
|
||||||
|
setRoleSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await request<RolePermRes>(`/admin/hq-permissions/roles/${role}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ permissionKeys: roleKeys }),
|
||||||
|
});
|
||||||
|
setRoleKeys(res.permissionKeys);
|
||||||
|
message.success('角色权限已保存');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setRoleSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccountPermissions() {
|
||||||
|
if (!accountId) return;
|
||||||
|
setAccountSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ permissionKeys: accountKeys }),
|
||||||
|
});
|
||||||
|
setAccountKeys(res.userPermissionKeys);
|
||||||
|
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||||
|
message.success('用户权限已保存');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setAccountSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||||
|
<Alert type="warning" showIcon message="仅超级管理员可配置权限" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
label: '按角色分配',
|
||||||
|
children: (
|
||||||
|
<Card loading={roleLoading}>
|
||||||
|
<Form layout="inline" style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item label="角色">
|
||||||
|
<Select
|
||||||
|
style={{ width: 180 }}
|
||||||
|
value={role}
|
||||||
|
onChange={setRole}
|
||||||
|
options={HQ_ADMIN_ROLES.map((r) => ({
|
||||||
|
value: r.value,
|
||||||
|
label: r.label,
|
||||||
|
disabled: r.value === 'SUPER_ADMIN',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
{role === 'SUPER_ADMIN' ? (
|
||||||
|
<Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Button type="primary" loading={roleSaving} onClick={() => void saveRolePermissions()}>
|
||||||
|
保存角色权限
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
label: '按用户分配',
|
||||||
|
children: (
|
||||||
|
<Card loading={accountLoading}>
|
||||||
|
<Form layout="inline" style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item label="HQ 账户">
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
placeholder="选择账户"
|
||||||
|
style={{ width: 320 }}
|
||||||
|
value={accountId}
|
||||||
|
onChange={setAccountId}
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={accounts
|
||||||
|
.filter((a) => a.adminRole !== 'SUPER_ADMIN')
|
||||||
|
.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
{!accountId ? (
|
||||||
|
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Space wrap style={{ marginBottom: 12 }}>
|
||||||
|
<span>角色继承:</span>
|
||||||
|
{roleInheritedKeys.map((key) => {
|
||||||
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||||
|
return (
|
||||||
|
<Tag key={key} color="blue">
|
||||||
|
{item?.label || key}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
||||||
|
<Space wrap style={{ marginTop: 12 }}>
|
||||||
|
<span>合并生效:</span>
|
||||||
|
{previewEffectiveKeys.map((key) => {
|
||||||
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||||
|
return <Tag key={key}>{item?.label || key}</Tag>;
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
||||||
|
保存用户权限
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,7 +83,6 @@ export default function StoresPage() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createStep, setCreateStep] = useState(0);
|
const [createStep, setCreateStep] = useState(0);
|
||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
|
||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
const [cities, setCities] = useState<CityOption[]>([]);
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
@@ -141,38 +140,9 @@ export default function StoresPage() {
|
|||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateStep(0);
|
setCreateStep(0);
|
||||||
setCreateError('');
|
setCreateError('');
|
||||||
setSmsCooldown(0);
|
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendCreateSms() {
|
|
||||||
const phone = String(createForm.getFieldValue('phone') ?? '').trim();
|
|
||||||
if (!/^1\d{10}$/.test(phone)) {
|
|
||||||
message.error('请先填写正确的11位门店手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (smsCooldown > 0) return;
|
|
||||||
try {
|
|
||||||
await request('/admin/stores/phone/sms/send', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone }),
|
|
||||||
});
|
|
||||||
message.success('验证码已发送');
|
|
||||||
setSmsCooldown(60);
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setSmsCooldown((s) => {
|
|
||||||
if (s <= 1) {
|
|
||||||
clearInterval(timer);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return s - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '发送失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
void loadOptions();
|
void loadOptions();
|
||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
@@ -192,7 +162,7 @@ export default function StoresPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'smsCode', 'address']);
|
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -220,7 +190,6 @@ export default function StoresPage() {
|
|||||||
city: values.city,
|
city: values.city,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
phone: values.phone.trim(),
|
phone: values.phone.trim(),
|
||||||
smsCode: values.smsCode.trim(),
|
|
||||||
district: values.district.trim(),
|
district: values.district.trim(),
|
||||||
address: values.address.trim(),
|
address: values.address.trim(),
|
||||||
intro: values.intro?.trim() || undefined,
|
intro: values.intro?.trim() || undefined,
|
||||||
@@ -239,7 +208,7 @@ export default function StoresPage() {
|
|||||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||||
const fields = e as { errorFields?: Array<{ name: string[] }> };
|
const fields = e as { errorFields?: Array<{ name: string[] }> };
|
||||||
const first = fields.errorFields?.[0]?.name?.[0];
|
const first = fields.errorFields?.[0]?.name?.[0];
|
||||||
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone' || first === 'smsCode') {
|
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
||||||
setCreateStep(0);
|
setCreateStep(0);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -409,16 +378,6 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
||||||
<Input placeholder="11位手机号" />
|
<Input placeholder="11位手机号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="短信验证" required>
|
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
|
||||||
<Form.Item name="smsCode" noStyle rules={[{ required: true, message: '请填写验证码' }]}>
|
|
||||||
<Input placeholder="验证码" maxLength={6} />
|
|
||||||
</Form.Item>
|
|
||||||
<Button disabled={smsCooldown > 0} onClick={() => void sendCreateSms()}>
|
|
||||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
|
||||||
</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||||
|
|
||||||
|
type Identity = {
|
||||||
|
actorType: ActorType;
|
||||||
|
actorId: string;
|
||||||
|
phone: string | null;
|
||||||
|
name: string | null;
|
||||||
|
wxOpenId: string;
|
||||||
|
wxUnionId: string | null;
|
||||||
|
phoneVerified?: boolean;
|
||||||
|
refLabel: string | null;
|
||||||
|
refId: string | null;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
status: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupRow = {
|
||||||
|
groupKey: string;
|
||||||
|
unionId: string | null;
|
||||||
|
identityCount: number;
|
||||||
|
actorTypes: ActorType[];
|
||||||
|
multiRole: boolean;
|
||||||
|
primaryPhone: string | null;
|
||||||
|
latestLoginAt: string | null;
|
||||||
|
identities: Identity[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTOR_TYPE_LABELS: Record<ActorType, string> = {
|
||||||
|
USER: 'C 端用户',
|
||||||
|
STORE: '门店账号',
|
||||||
|
PARTNER: '合伙人账号',
|
||||||
|
HQ: 'HQ 账号',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTOR_TYPE_COLORS: Record<ActorType, string> = {
|
||||||
|
USER: 'blue',
|
||||||
|
STORE: 'green',
|
||||||
|
PARTNER: 'orange',
|
||||||
|
HQ: 'purple',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderActorTags(types: ActorType[]) {
|
||||||
|
return types.map((t) => (
|
||||||
|
<Tag key={t} color={ACTOR_TYPE_COLORS[t]}>
|
||||||
|
{ACTOR_TYPE_LABELS[t]}
|
||||||
|
</Tag>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WechatBindingsPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({
|
||||||
|
actorType: '',
|
||||||
|
phone: '',
|
||||||
|
unionId: '',
|
||||||
|
openId: '',
|
||||||
|
});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<GroupRow>(
|
||||||
|
'/admin/wechat-bindings',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.actorType) qs.set('actorType', filters.actorType);
|
||||||
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
|
if (filters.unionId) qs.set('unionId', filters.unionId);
|
||||||
|
if (filters.openId) qs.set('openId', filters.openId);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<GroupRow | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue(filters);
|
||||||
|
}, [form, filters]);
|
||||||
|
|
||||||
|
async function openDetail(row: GroupRow) {
|
||||||
|
const res = await request<GroupRow>(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<GroupRow> = [
|
||||||
|
{
|
||||||
|
title: 'unionId',
|
||||||
|
dataIndex: 'unionId',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || <Tag>无 unionId</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '身份数',
|
||||||
|
dataIndex: 'identityCount',
|
||||||
|
width: 90,
|
||||||
|
render: (v, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{r.multiRole ? <Tag color="red">一人多角色</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '端类型',
|
||||||
|
dataIndex: 'actorTypes',
|
||||||
|
width: 220,
|
||||||
|
render: (types: ActorType[]) => renderActorTags(types),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '手机号',
|
||||||
|
dataIndex: 'primaryPhone',
|
||||||
|
width: 140,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '身份摘要',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => (
|
||||||
|
<AdminCellLine
|
||||||
|
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
|
||||||
|
secondary={r.identities
|
||||||
|
.map((i) => i.refLabel || i.name || i.phone)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近登录',
|
||||||
|
dataIndex: 'latestLoginAt',
|
||||||
|
width: 160,
|
||||||
|
render: fmtTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const identityColumns: ColumnsType<Identity> = [
|
||||||
|
{
|
||||||
|
title: '端类型',
|
||||||
|
dataIndex: 'actorType',
|
||||||
|
width: 120,
|
||||||
|
render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账号',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => (
|
||||||
|
<AdminCellLine
|
||||||
|
primary={r.name || '—'}
|
||||||
|
secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '归属',
|
||||||
|
dataIndex: 'refLabel',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'wxOpenId',
|
||||||
|
dataIndex: 'wxOpenId',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '手机验证',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.actorType === 'USER' ? (
|
||||||
|
r.phoneVerified ? <Tag color="blue">已验证</Tag> : <Tag>未验证</Tag>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近登录',
|
||||||
|
dataIndex: 'lastLoginAt',
|
||||||
|
width: 160,
|
||||||
|
render: fmtTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => String(v),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>微信绑定总览</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(values) => {
|
||||||
|
setPage(1);
|
||||||
|
setFilters({
|
||||||
|
actorType: values.actorType ?? '',
|
||||||
|
phone: values.phone?.trim() ?? '',
|
||||||
|
unionId: values.unionId?.trim() ?? '',
|
||||||
|
openId: values.openId?.trim() ?? '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="actorType" label="端类型">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'USER', label: 'C 端用户' },
|
||||||
|
{ value: 'STORE', label: '门店账号' },
|
||||||
|
{ value: 'PARTNER', label: '合伙人账号' },
|
||||||
|
{ value: 'HQ', label: 'HQ 账号' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号">
|
||||||
|
<Input allowClear placeholder="模糊匹配" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="unionId" label="unionId">
|
||||||
|
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="openId" label="openId">
|
||||||
|
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
setFilters({ actorType: '', phone: '', unionId: '', openId: '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void reload()}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table<GroupRow>
|
||||||
|
rowKey="groupKey"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="微信绑定详情"
|
||||||
|
width={960}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
>
|
||||||
|
{detail ? (
|
||||||
|
<>
|
||||||
|
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="groupKey">{detail.groupKey}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="unionId">{detail.unionId || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="身份数">{detail.identityCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="端类型">{renderActorTags(detail.actorTypes)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="一人多角色">
|
||||||
|
{detail.multiRole ? <Tag color="red">是</Tag> : <Tag>否</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最近登录">{fmtTime(detail.latestLoginAt)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Table<Identity>
|
||||||
|
rowKey={(r) => `${r.actorType}-${r.actorId}`}
|
||||||
|
size="small"
|
||||||
|
columns={identityColumns}
|
||||||
|
dataSource={detail.identities}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export const HQ_PERMISSION_CATALOG = [
|
||||||
|
{ key: 'dashboard', label: '概览' },
|
||||||
|
{ key: 'users', label: '用户管理' },
|
||||||
|
{ key: 'wechat_bindings', label: '微信绑定' },
|
||||||
|
{ key: 'products', label: '商品管理' },
|
||||||
|
{ key: 'orders', label: '订单管理' },
|
||||||
|
{ key: 'stores', label: '门店管理' },
|
||||||
|
{ key: 'partners', label: '开城管理' },
|
||||||
|
{ key: 'benefit', label: '好客权益' },
|
||||||
|
{ key: 'deliveries', label: '配送单' },
|
||||||
|
{ key: 'tickets', label: '工单中心' },
|
||||||
|
{ key: 'resources', label: 'OSS 资源库' },
|
||||||
|
{ key: 'logs', label: '日志' },
|
||||||
|
{ key: 'hq_permissions', label: '权限分配' },
|
||||||
|
{ key: 'hq_accounts', label: 'HQ 账户' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
||||||
|
|
||||||
|
export const HQ_ADMIN_ROLES = [
|
||||||
|
{ value: 'SUPER_ADMIN', label: '超级管理员' },
|
||||||
|
{ value: 'OPS', label: '运营' },
|
||||||
|
{ value: 'FINANCE', label: '财务' },
|
||||||
|
{ value: 'CUSTOMER_SERVICE', label: '客服' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
||||||
|
SUPER_ADMIN: HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||||
|
OPS: [
|
||||||
|
'dashboard',
|
||||||
|
'users',
|
||||||
|
'wechat_bindings',
|
||||||
|
'products',
|
||||||
|
'orders',
|
||||||
|
'stores',
|
||||||
|
'partners',
|
||||||
|
'benefit',
|
||||||
|
'deliveries',
|
||||||
|
'tickets',
|
||||||
|
'resources',
|
||||||
|
'logs',
|
||||||
|
],
|
||||||
|
FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'logs'],
|
||||||
|
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'logs'],
|
||||||
|
};
|
||||||
@@ -13,3 +13,4 @@ export * from './user-log';
|
|||||||
export * from './store-log';
|
export * from './store-log';
|
||||||
export * from './partner-log';
|
export * from './partner-log';
|
||||||
export * from './promo';
|
export * from './promo';
|
||||||
|
export * from './hq-permissions';
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
if (!refreshed.accessToken) throw new Error('refresh failed');
|
if (!refreshed.accessToken) throw new Error('refresh failed');
|
||||||
|
|
||||||
console.log('4. STORE_ACCOUNT_OPEN occupied phone');
|
console.log('4. Admin create store rejects occupied phone');
|
||||||
await req('HQ_WEB', '/admin/auth/sms/send', {
|
await req('HQ_WEB', '/admin/auth/sms/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone: '13600000001', scene: 'HQ_LOGIN' }),
|
body: JSON.stringify({ phone: '13600000001', scene: 'HQ_LOGIN' }),
|
||||||
@@ -66,10 +66,17 @@ async function main() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone: '13600000001', code: MOCK_SMS_CODE }),
|
body: JSON.stringify({ phone: '13600000001', code: MOCK_SMS_CODE }),
|
||||||
});
|
});
|
||||||
const occupied = await expectFail('HQ_WEB', '/admin/stores/phone/sms/send', {
|
const occupied = await expectFail('HQ_WEB', '/admin/stores', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: admin.accessToken,
|
token: admin.accessToken,
|
||||||
body: JSON.stringify({ phone: '13910000001' }),
|
body: JSON.stringify({
|
||||||
|
partnerId: '1',
|
||||||
|
cityId: '1',
|
||||||
|
name: '重复手机号测试店',
|
||||||
|
phone: '13910000001',
|
||||||
|
district: '测试区',
|
||||||
|
address: '测试地址1号',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!occupied.includes('已绑定')) throw new Error(`unexpected: ${occupied}`);
|
if (!occupied.includes('已绑定')) throw new Error(`unexpected: ${occupied}`);
|
||||||
|
|
||||||
|
|||||||
@@ -506,9 +506,31 @@ model HqAccount {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
permissions HqAccountPermission[]
|
||||||
|
|
||||||
@@map("hq_account")
|
@@map("hq_account")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model HqRolePermission {
|
||||||
|
adminRole HqAdminRole @map("admin_role")
|
||||||
|
permissionKey String @map("permission_key") @db.VarChar(64)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
@@id([adminRole, permissionKey])
|
||||||
|
@@map("hq_role_permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
model HqAccountPermission {
|
||||||
|
hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
|
||||||
|
permissionKey String @map("permission_key") @db.VarChar(64)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([hqAccountId, permissionKey])
|
||||||
|
@@map("hq_account_permission")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── USER ─────────────────────────────────────────────
|
// ─── USER ─────────────────────────────────────────────
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const HqOperationAction = {
|
|||||||
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
|
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
|
||||||
HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE',
|
HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE',
|
||||||
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
|
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
|
||||||
|
HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE',
|
||||||
USER_DELETE: 'USER_DELETE',
|
USER_DELETE: 'USER_DELETE',
|
||||||
USER_BATCH_DELETE: 'USER_BATCH_DELETE',
|
USER_BATCH_DELETE: 'USER_BATCH_DELETE',
|
||||||
ORDER_SHIP: 'ORDER_SHIP',
|
ORDER_SHIP: 'ORDER_SHIP',
|
||||||
@@ -50,7 +51,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
||||||
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
|
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
|
||||||
[HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员',
|
[HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员',
|
||||||
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员/权限',
|
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员',
|
||||||
|
[HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限',
|
||||||
[HqOperationAction.USER_DELETE]: '删除用户',
|
[HqOperationAction.USER_DELETE]: '删除用户',
|
||||||
[HqOperationAction.USER_BATCH_DELETE]: '批量删除用户',
|
[HqOperationAction.USER_BATCH_DELETE]: '批量删除用户',
|
||||||
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
||||||
|
|||||||
@@ -4,6 +4,31 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
|||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto';
|
import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto';
|
||||||
import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto';
|
import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto';
|
||||||
|
import { hashPassword } from '../../common/crypto/password.util';
|
||||||
|
|
||||||
|
function mapHqAccountRow(account: {
|
||||||
|
id: bigint;
|
||||||
|
phone: string;
|
||||||
|
loginName: string | null;
|
||||||
|
passwordHash: string | null;
|
||||||
|
name: string;
|
||||||
|
adminRole: string;
|
||||||
|
status: string;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
phone: account.phone,
|
||||||
|
loginName: account.loginName,
|
||||||
|
hasPassword: !!account.passwordHash,
|
||||||
|
name: account.name,
|
||||||
|
adminRole: account.adminRole,
|
||||||
|
status: account.status,
|
||||||
|
lastLoginAt: account.lastLoginAt,
|
||||||
|
createdAt: account.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminHqAccountsService {
|
export class AdminHqAccountsService {
|
||||||
@@ -23,42 +48,130 @@ export class AdminHqAccountsService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
phone: true,
|
||||||
|
loginName: true,
|
||||||
|
passwordHash: true,
|
||||||
|
name: true,
|
||||||
|
adminRole: true,
|
||||||
|
status: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.hqAccount.count({ where }),
|
this.prisma.hqAccount.count({ where }),
|
||||||
]);
|
]);
|
||||||
return serializeBigInt({ items, total, page, pageSize });
|
return serializeBigInt({
|
||||||
|
items: items.map(mapHqAccountRow),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async detail(id: bigint) {
|
async detail(id: bigint) {
|
||||||
const account = await this.prisma.hqAccount.findUnique({ where: { id } });
|
const account = await this.prisma.hqAccount.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
phone: true,
|
||||||
|
loginName: true,
|
||||||
|
passwordHash: true,
|
||||||
|
name: true,
|
||||||
|
adminRole: true,
|
||||||
|
status: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!account) throw new NotFoundException('HQ 账号不存在');
|
if (!account) throw new NotFoundException('HQ 账号不存在');
|
||||||
return serializeBigInt(account);
|
return serializeBigInt(mapHqAccountRow(account));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateHqAccountDto) {
|
async create(dto: CreateHqAccountDto) {
|
||||||
const exists = await this.prisma.hqAccount.findUnique({ where: { phone: dto.phone } });
|
const adminRole = (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
|
||||||
if (exists) throw new BadRequestException('手机号已存在');
|
|
||||||
|
if (dto.credentialType === 'phone') {
|
||||||
|
if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号');
|
||||||
|
const phone = dto.phone.trim();
|
||||||
|
const exists = await this.prisma.hqAccount.findUnique({ where: { phone } });
|
||||||
|
if (exists) throw new BadRequestException('手机号已存在');
|
||||||
|
const account = await this.prisma.hqAccount.create({
|
||||||
|
data: { phone, name: dto.name, adminRole },
|
||||||
|
});
|
||||||
|
return serializeBigInt(mapHqAccountRow({ ...account, passwordHash: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.loginName?.trim() || !dto.password) {
|
||||||
|
throw new BadRequestException('账号密码模式需填写用户名和密码');
|
||||||
|
}
|
||||||
|
const loginName = dto.loginName.trim();
|
||||||
|
const loginTaken = await this.prisma.hqAccount.findUnique({ where: { loginName } });
|
||||||
|
if (loginTaken) throw new BadRequestException('用户名已存在');
|
||||||
|
|
||||||
|
const phone = dto.phone?.trim() || (await this.generatePlaceholderPhone());
|
||||||
|
const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } });
|
||||||
|
if (phoneTaken) throw new BadRequestException('手机号已存在');
|
||||||
|
|
||||||
const account = await this.prisma.hqAccount.create({
|
const account = await this.prisma.hqAccount.create({
|
||||||
data: {
|
data: {
|
||||||
phone: dto.phone,
|
phone,
|
||||||
|
loginName,
|
||||||
|
passwordHash: hashPassword(dto.password),
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
adminRole: (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE',
|
adminRole,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return serializeBigInt(account);
|
return serializeBigInt(mapHqAccountRow(account));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateHqAccountDto) {
|
async update(id: bigint, dto: UpdateHqAccountDto) {
|
||||||
|
const current = await this.prisma.hqAccount.findUnique({ where: { id } });
|
||||||
|
if (!current) throw new NotFoundException('HQ 账号不存在');
|
||||||
|
|
||||||
|
if (dto.loginName !== undefined) {
|
||||||
|
const loginName = dto.loginName.trim();
|
||||||
|
if (!loginName) throw new BadRequestException('用户名不能为空');
|
||||||
|
const conflict = await this.prisma.hqAccount.findFirst({
|
||||||
|
where: { loginName, id: { not: id } },
|
||||||
|
});
|
||||||
|
if (conflict) throw new BadRequestException('用户名已存在');
|
||||||
|
}
|
||||||
|
|
||||||
const account = await this.prisma.hqAccount.update({
|
const account = await this.prisma.hqAccount.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||||
|
...(dto.loginName !== undefined ? { loginName: dto.loginName.trim() } : {}),
|
||||||
|
...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}),
|
||||||
...(dto.adminRole !== undefined
|
...(dto.adminRole !== undefined
|
||||||
? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }
|
? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }
|
||||||
: {}),
|
: {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
|
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
phone: true,
|
||||||
|
loginName: true,
|
||||||
|
passwordHash: true,
|
||||||
|
name: true,
|
||||||
|
adminRole: true,
|
||||||
|
status: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return serializeBigInt(account);
|
return serializeBigInt(mapHqAccountRow(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generatePlaceholderPhone(): Promise<string> {
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`.slice(-8);
|
||||||
|
const phone = `199${suffix}`;
|
||||||
|
const exists = await this.prisma.hqAccount.findUnique({ where: { phone } });
|
||||||
|
if (!exists) return phone;
|
||||||
|
}
|
||||||
|
throw new BadRequestException('无法生成占位手机号,请手动填写');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
|
import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||||
|
import { SaveHqAccountPermissionsDto, SaveHqRolePermissionsDto } from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
|
@Controller('admin/hq-permissions')
|
||||||
|
@UseGuards(HqAuthGuard, SuperAdminGuard)
|
||||||
|
export class AdminHqPermissionsController {
|
||||||
|
constructor(private readonly service: AdminHqPermissionsService) {}
|
||||||
|
|
||||||
|
@Get('catalog')
|
||||||
|
catalog() {
|
||||||
|
return this.service.catalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('roles/:role')
|
||||||
|
getRolePermissions(@Param('role') role: string) {
|
||||||
|
return this.service.getRolePermissions(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('roles/:role')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.HQ_PERMISSION_UPDATE,
|
||||||
|
refType: 'HQ_ROLE',
|
||||||
|
refIdParam: 'role',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
saveRolePermissions(@Param('role') role: string, @Body() dto: SaveHqRolePermissionsDto) {
|
||||||
|
return this.service.saveRolePermissions(role, dto.permissionKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('accounts/:id')
|
||||||
|
getAccountPermissions(@Param('id') id: string) {
|
||||||
|
return this.service.getAccountPermissions(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('accounts/:id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.HQ_PERMISSION_UPDATE,
|
||||||
|
refType: 'HQ_ACCOUNT',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
saveAccountPermissions(@Param('id') id: string, @Body() dto: SaveHqAccountPermissionsDto) {
|
||||||
|
return this.service.saveAccountPermissions(BigInt(id), dto.permissionKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
HQ_PERMISSION_CATALOG,
|
||||||
|
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||||
|
type HqPermissionKey,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
const VALID_PERMISSION_KEYS = new Set<string>(HQ_PERMISSION_CATALOG.map((p) => p.key));
|
||||||
|
|
||||||
|
function assertPermissionKeys(keys: string[]) {
|
||||||
|
const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key));
|
||||||
|
if (invalid.length) {
|
||||||
|
throw new BadRequestException(`无效权限项: ${invalid.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminHqPermissionsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
catalog() {
|
||||||
|
return {
|
||||||
|
permissions: HQ_PERMISSION_CATALOG,
|
||||||
|
roles: Object.entries(HQ_ROLE_DEFAULT_PERMISSIONS).map(([role, permissionKeys]) => ({
|
||||||
|
role,
|
||||||
|
permissionKeys,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRolePermissions(role: string) {
|
||||||
|
const rows = await this.prisma.hqRolePermission.findMany({
|
||||||
|
where: { adminRole: role as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' },
|
||||||
|
select: { permissionKey: true },
|
||||||
|
});
|
||||||
|
const permissionKeys =
|
||||||
|
rows.length > 0
|
||||||
|
? rows.map((r) => r.permissionKey)
|
||||||
|
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])];
|
||||||
|
return { role, permissionKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveRolePermissions(role: string, permissionKeys: string[]) {
|
||||||
|
if (role === 'SUPER_ADMIN') {
|
||||||
|
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
||||||
|
}
|
||||||
|
assertPermissionKeys(permissionKeys);
|
||||||
|
const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }),
|
||||||
|
...(permissionKeys.length
|
||||||
|
? [
|
||||||
|
this.prisma.hqRolePermission.createMany({
|
||||||
|
data: permissionKeys.map((permissionKey) => ({ adminRole, permissionKey })),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
return this.getRolePermissions(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountPermissions(accountId: bigint) {
|
||||||
|
const account = await this.prisma.hqAccount.findUnique({
|
||||||
|
where: { id: accountId },
|
||||||
|
select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true },
|
||||||
|
});
|
||||||
|
if (!account) throw new NotFoundException('HQ 账号不存在');
|
||||||
|
if (account.adminRole === 'SUPER_ADMIN') {
|
||||||
|
return serializeBigInt({
|
||||||
|
account,
|
||||||
|
permissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||||
|
rolePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||||
|
userPermissionKeys: [],
|
||||||
|
effectivePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rolePerms, userPerms] = await Promise.all([
|
||||||
|
this.getRolePermissions(account.adminRole),
|
||||||
|
this.prisma.hqAccountPermission.findMany({
|
||||||
|
where: { hqAccountId: accountId },
|
||||||
|
select: { permissionKey: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const userPermissionKeys = userPerms.map((p) => p.permissionKey);
|
||||||
|
const effectivePermissionKeys = [
|
||||||
|
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
|
||||||
|
] as HqPermissionKey[];
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
account,
|
||||||
|
permissionKeys: userPermissionKeys,
|
||||||
|
rolePermissionKeys: rolePerms.permissionKeys,
|
||||||
|
userPermissionKeys,
|
||||||
|
effectivePermissionKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) {
|
||||||
|
const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } });
|
||||||
|
if (!account) throw new NotFoundException('HQ 账号不存在');
|
||||||
|
if (account.adminRole === 'SUPER_ADMIN') {
|
||||||
|
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
||||||
|
}
|
||||||
|
assertPermissionKeys(permissionKeys);
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }),
|
||||||
|
...(permissionKeys.length
|
||||||
|
? [
|
||||||
|
this.prisma.hqAccountPermission.createMany({
|
||||||
|
data: permissionKeys.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
return this.getAccountPermissions(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,6 @@ export class AdminStoresController {
|
|||||||
return this.service.listStores(query);
|
return this.service.listStores(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('phone/sms/send')
|
|
||||||
sendOpenSms(@Body() body: { phone: string }) {
|
|
||||||
return this.service.sendStoreOpenSms(body.phone);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
return this.service.detailStore(BigInt(id));
|
return this.service.detailStore(BigInt(id));
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
import { AuthService } from '../iam/auth.service';
|
|
||||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||||
import type {
|
import type {
|
||||||
CreateStoreAccountDto,
|
CreateStoreAccountDto,
|
||||||
@@ -18,16 +16,7 @@ import type {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminStoresService {
|
export class AdminStoresService {
|
||||||
constructor(
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async sendStoreOpenSms(phone: string) {
|
|
||||||
return this.authService.sendSms(phone, SmsScene.STORE_ACCOUNT_OPEN, {
|
|
||||||
clientApp: ClientApp.HQ_WEB,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async listStores(query: AdminStoresQueryDto) {
|
async listStores(query: AdminStoresQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
@@ -168,7 +157,6 @@ export class AdminStoresService {
|
|||||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||||
throw new BadRequestException('请输入正确的手机号码');
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
}
|
}
|
||||||
await this.authService.verifySmsCode(normalizedPhone, dto.smsCode, SmsScene.STORE_ACCOUNT_OPEN);
|
|
||||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||||
where: { phone: normalizedPhone },
|
where: { phone: normalizedPhone },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
|
||||||
|
import { AdminWechatBindingsQueryDto } from './dto/admin-query.dto';
|
||||||
|
|
||||||
|
@Controller('admin/wechat-bindings')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminWechatBindingsController {
|
||||||
|
constructor(private readonly wechatBindingsService: AdminWechatBindingsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: AdminWechatBindingsQueryDto) {
|
||||||
|
return this.wechatBindingsService.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':groupKey')
|
||||||
|
detail(@Param('groupKey') groupKey: string) {
|
||||||
|
return this.wechatBindingsService.detail(decodeURIComponent(groupKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { AdminWechatBindingsQueryDto } from './dto/admin-query.dto';
|
||||||
|
|
||||||
|
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||||
|
|
||||||
|
type BindingRow = {
|
||||||
|
actorType: ActorType;
|
||||||
|
actorId: bigint;
|
||||||
|
phone: string | null;
|
||||||
|
name: string | null;
|
||||||
|
wxOpenId: string;
|
||||||
|
wxUnionId: string | null;
|
||||||
|
phoneVerified?: boolean;
|
||||||
|
refLabel?: string | null;
|
||||||
|
refId?: bigint | null;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
status: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildGroupKey(row: BindingRow): string {
|
||||||
|
if (row.wxUnionId) return `union:${row.wxUnionId}`;
|
||||||
|
return `solo:${row.actorType}:${row.actorId.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBindingRow(row: BindingRow) {
|
||||||
|
return {
|
||||||
|
actorType: row.actorType,
|
||||||
|
actorId: row.actorId.toString(),
|
||||||
|
phone: row.phone,
|
||||||
|
name: row.name,
|
||||||
|
wxOpenId: row.wxOpenId,
|
||||||
|
wxUnionId: row.wxUnionId,
|
||||||
|
phoneVerified: row.phoneVerified,
|
||||||
|
refLabel: row.refLabel ?? null,
|
||||||
|
refId: row.refId?.toString() ?? null,
|
||||||
|
lastLoginAt: row.lastLoginAt,
|
||||||
|
status: row.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeGroup(groupKey: string, rows: BindingRow[]) {
|
||||||
|
const unionId = groupKey.startsWith('union:') ? groupKey.slice('union:'.length) : null;
|
||||||
|
const actorTypes = [...new Set(rows.map((r) => r.actorType))];
|
||||||
|
const phones = rows.map((r) => r.phone).filter((p): p is string => !!p);
|
||||||
|
const latestLoginAt = rows.reduce<Date | null>((max, r) => {
|
||||||
|
if (!r.lastLoginAt) return max;
|
||||||
|
if (!max || r.lastLoginAt > max) return r.lastLoginAt;
|
||||||
|
return max;
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupKey,
|
||||||
|
unionId,
|
||||||
|
identityCount: rows.length,
|
||||||
|
actorTypes,
|
||||||
|
multiRole: rows.length > 1,
|
||||||
|
primaryPhone: phones[0] ?? null,
|
||||||
|
latestLoginAt,
|
||||||
|
identities: rows.map(mapBindingRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminWechatBindingsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(query: AdminWechatBindingsQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
let rows = await this.fetchBindings(query);
|
||||||
|
const shouldExpandUnion = !!(query.phone || query.openId || query.actorType);
|
||||||
|
if (shouldExpandUnion) {
|
||||||
|
const unionIds = [
|
||||||
|
...new Set(rows.map((r) => r.wxUnionId).filter((id): id is string => !!id)),
|
||||||
|
];
|
||||||
|
if (unionIds.length > 0) {
|
||||||
|
const expanded = (
|
||||||
|
await Promise.all(unionIds.map((unionId) => this.fetchBindings({ unionId })))
|
||||||
|
).flat();
|
||||||
|
const soloRows = rows.filter((r) => !r.wxUnionId);
|
||||||
|
rows = this.dedupeBindings([...expanded, ...soloRows]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groups = this.groupBindings(rows);
|
||||||
|
const summaries = [...groups.entries()]
|
||||||
|
.map(([groupKey, groupRows]) => summarizeGroup(groupKey, groupRows))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.multiRole !== b.multiRole) return a.multiRole ? -1 : 1;
|
||||||
|
const ta = a.latestLoginAt ? new Date(a.latestLoginAt).getTime() : 0;
|
||||||
|
const tb = b.latestLoginAt ? new Date(b.latestLoginAt).getTime() : 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = summaries.length;
|
||||||
|
const items = summaries.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
|
return serializeBigInt({ items, total, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(groupKey: string) {
|
||||||
|
const rows = await this.fetchBindings({});
|
||||||
|
const groups = this.groupBindings(rows);
|
||||||
|
const groupRows = groups.get(groupKey);
|
||||||
|
if (!groupRows?.length) {
|
||||||
|
throw new NotFoundException('微信绑定分组不存在');
|
||||||
|
}
|
||||||
|
return serializeBigInt(summarizeGroup(groupKey, groupRows));
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupBindings(rows: BindingRow[]): Map<string, BindingRow[]> {
|
||||||
|
const groups = new Map<string, BindingRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = buildGroupKey(row);
|
||||||
|
const list = groups.get(key) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
for (const [key, list] of groups) {
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const ta = a.lastLoginAt?.getTime() ?? 0;
|
||||||
|
const tb = b.lastLoginAt?.getTime() ?? 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchBindings(query: AdminWechatBindingsQueryDto): Promise<BindingRow[]> {
|
||||||
|
const actorType = query.actorType as ActorType | undefined;
|
||||||
|
const phoneFilter = query.phone?.trim();
|
||||||
|
const unionIdFilter = query.unionId?.trim();
|
||||||
|
const openIdFilter = query.openId?.trim();
|
||||||
|
|
||||||
|
const rows: BindingRow[] = [];
|
||||||
|
|
||||||
|
if (!actorType || actorType === 'USER') {
|
||||||
|
const where: Prisma.UserWhereInput = {
|
||||||
|
wxOpenId: { not: null },
|
||||||
|
status: 1,
|
||||||
|
mergedIntoUserId: null,
|
||||||
|
};
|
||||||
|
if (phoneFilter) where.phone = { contains: phoneFilter };
|
||||||
|
if (unionIdFilter) where.wxUnionId = unionIdFilter;
|
||||||
|
if (openIdFilter) where.wxOpenId = openIdFilter;
|
||||||
|
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
phone: true,
|
||||||
|
nickname: true,
|
||||||
|
phoneVerifiedAt: true,
|
||||||
|
wxOpenId: true,
|
||||||
|
wxUnionId: true,
|
||||||
|
status: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
if (!u.wxOpenId) continue;
|
||||||
|
rows.push({
|
||||||
|
actorType: 'USER',
|
||||||
|
actorId: u.id,
|
||||||
|
phone: u.phone,
|
||||||
|
name: u.nickname,
|
||||||
|
wxOpenId: u.wxOpenId,
|
||||||
|
wxUnionId: u.wxUnionId,
|
||||||
|
phoneVerified: !!u.phoneVerifiedAt,
|
||||||
|
lastLoginAt: u.updatedAt,
|
||||||
|
status: u.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actorType || actorType === 'STORE') {
|
||||||
|
const where: Prisma.StoreAccountWhereInput = { wxOpenId: { not: null } };
|
||||||
|
if (phoneFilter) where.phone = { contains: phoneFilter };
|
||||||
|
if (unionIdFilter) where.wxUnionId = unionIdFilter;
|
||||||
|
if (openIdFilter) where.wxOpenId = openIdFilter;
|
||||||
|
|
||||||
|
const accounts = await this.prisma.storeAccount.findMany({
|
||||||
|
where,
|
||||||
|
include: { store: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (!a.wxOpenId) continue;
|
||||||
|
rows.push({
|
||||||
|
actorType: 'STORE',
|
||||||
|
actorId: a.id,
|
||||||
|
phone: a.phone,
|
||||||
|
name: a.name,
|
||||||
|
wxOpenId: a.wxOpenId,
|
||||||
|
wxUnionId: a.wxUnionId,
|
||||||
|
refId: a.storeId,
|
||||||
|
refLabel: a.store.name,
|
||||||
|
lastLoginAt: a.lastLoginAt,
|
||||||
|
status: a.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actorType || actorType === 'PARTNER') {
|
||||||
|
const where: Prisma.PartnerAccountWhereInput = { wxOpenId: { not: null } };
|
||||||
|
if (phoneFilter) where.phone = { contains: phoneFilter };
|
||||||
|
if (unionIdFilter) where.wxUnionId = unionIdFilter;
|
||||||
|
if (openIdFilter) where.wxOpenId = openIdFilter;
|
||||||
|
|
||||||
|
const accounts = await this.prisma.partnerAccount.findMany({
|
||||||
|
where,
|
||||||
|
include: { partner: { select: { id: true, companyName: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (!a.wxOpenId) continue;
|
||||||
|
rows.push({
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: a.id,
|
||||||
|
phone: a.phone,
|
||||||
|
name: a.name,
|
||||||
|
wxOpenId: a.wxOpenId,
|
||||||
|
wxUnionId: a.wxUnionId,
|
||||||
|
refId: a.partnerId,
|
||||||
|
refLabel: a.partner.companyName,
|
||||||
|
lastLoginAt: a.lastLoginAt,
|
||||||
|
status: a.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actorType || actorType === 'HQ') {
|
||||||
|
const where: Prisma.HqAccountWhereInput = { wxOpenId: { not: null } };
|
||||||
|
if (phoneFilter) where.phone = { contains: phoneFilter };
|
||||||
|
if (unionIdFilter) where.wxUnionId = unionIdFilter;
|
||||||
|
if (openIdFilter) where.wxOpenId = openIdFilter;
|
||||||
|
|
||||||
|
const accounts = await this.prisma.hqAccount.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
phone: true,
|
||||||
|
name: true,
|
||||||
|
adminRole: true,
|
||||||
|
wxOpenId: true,
|
||||||
|
wxUnionId: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (!a.wxOpenId) continue;
|
||||||
|
rows.push({
|
||||||
|
actorType: 'HQ',
|
||||||
|
actorId: a.id,
|
||||||
|
phone: a.phone,
|
||||||
|
name: a.name,
|
||||||
|
wxOpenId: a.wxOpenId,
|
||||||
|
wxUnionId: a.wxUnionId,
|
||||||
|
refLabel: a.adminRole,
|
||||||
|
lastLoginAt: a.lastLoginAt,
|
||||||
|
status: a.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dedupeBindings(rows: BindingRow[]): BindingRow[] {
|
||||||
|
const map = new Map<string, BindingRow>();
|
||||||
|
for (const row of rows) {
|
||||||
|
map.set(`${row.actorType}:${row.actorId.toString()}`, row);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateStoreStatusDto {
|
export class UpdateStoreStatusDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -24,10 +36,6 @@ export class CreateStoreDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
smsCode: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
@@ -417,9 +425,22 @@ export class UpdateDeliveryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateHqAccountDto {
|
export class CreateHqAccountDto {
|
||||||
|
@IsIn(['phone', 'password'])
|
||||||
|
credentialType: 'phone' | 'password';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ValidateIf((o: CreateHqAccountDto) => o.credentialType === 'password')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
loginName?: string;
|
||||||
|
|
||||||
|
@ValidateIf((o: CreateHqAccountDto) => o.credentialType === 'password')
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -431,6 +452,14 @@ export class CreateHqAccountDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateHqAccountDto {
|
export class UpdateHqAccountDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
loginName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password?: string;
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -444,6 +473,18 @@ export class UpdateHqAccountDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SaveHqRolePermissionsDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissionKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaveHqAccountPermissionsDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissionKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateProductDto {
|
export class CreateProductDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -388,3 +388,21 @@ export class AdminPromoCodesQueryDto extends PaginationQueryDto {
|
|||||||
@IsIn(['ACTIVE', 'DISABLED'])
|
@IsIn(['ACTIVE', 'DISABLED'])
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AdminWechatBindingsQueryDto extends PaginationQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['USER', 'STORE', 'PARTNER', 'HQ'])
|
||||||
|
actorType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
unionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
openId?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
|
|||||||
import { RedeemModule } from '../redeem/redeem.module';
|
import { RedeemModule } from '../redeem/redeem.module';
|
||||||
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
|
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
|
||||||
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||||
|
import { AdminWechatBindingsController } from './admin-wechat-bindings.controller';
|
||||||
|
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
|
||||||
|
import { AdminHqPermissionsController } from './admin-hq-permissions.controller';
|
||||||
|
import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||||
@@ -72,6 +76,8 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
|||||||
AdminProductDetailTemplatesController,
|
AdminProductDetailTemplatesController,
|
||||||
AdminRedeemDebugController,
|
AdminRedeemDebugController,
|
||||||
AdminPromoCodesController,
|
AdminPromoCodesController,
|
||||||
|
AdminWechatBindingsController,
|
||||||
|
AdminHqPermissionsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminDashboardService,
|
AdminDashboardService,
|
||||||
@@ -94,6 +100,8 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
|||||||
AdminProductDetailTemplatesService,
|
AdminProductDetailTemplatesService,
|
||||||
AdminRedeemDebugService,
|
AdminRedeemDebugService,
|
||||||
AdminPromoCodesService,
|
AdminPromoCodesService,
|
||||||
|
AdminWechatBindingsService,
|
||||||
|
AdminHqPermissionsService,
|
||||||
SuperAdminGuard,
|
SuperAdminGuard,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user