Compare commits

..

10 Commits

Author SHA1 Message Date
447934d5ff 💥 feat(模块): 通告列表增加微信号和手机号 2025-11-06 19:42:45 +08:00
6f4ef68db4 feat: add 2025-10-30 23:29:44 +08:00
aebb3d744e 💥 feat(模块): h 2025-10-20 08:00:27 +08:00
3d29ecbf1e feat: update 2025-10-19 17:04:49 +08:00
e6d1838682 feat: update select 2025-10-13 23:48:44 +08:00
4991720abb feat: 群认证 2025-10-13 23:33:58 +08:00
11f2624889 feat: update 2025-09-20 20:12:45 +08:00
55ceed2b2e feat: 发布人优先级 2025-09-19 22:45:32 +08:00
88977f36cd Merge branch 'trunk' of https://git.littlefat.tech/magic-csb/boluo-admin-main into trunk 2025-09-17 00:13:36 +08:00
e98bdcee49 feat: update 2025-09-17 00:10:59 +08:00
14 changed files with 641 additions and 18 deletions

View File

@ -11,16 +11,16 @@
*/ */
export default { export default {
// 如果需要自定义本地开发服务器 请取消注释按需调整 // 如果需要自定义本地开发服务器 请取消注释按需调整
// dev: { dev: {
// localhost:8000/api/** -> https://preview.pro.ant.design/api/** // localhost:8000/api/** -> https://preview.pro.ant.design/api/**
// '/api/': { '/api/': {
// // 要代理的地址 // 要代理的地址
// target: 'http://10.147.18.100:8082', target: 'https://dev.neighbourhood.cn',
// // 配置了这个可以从 http 代理到 https // 配置了这个可以从 http 代理到 https
// // 依赖 origin 的功能可能需要这个,比如 cookie // 依赖 origin 的功能可能需要这个,比如 cookie
// changeOrigin: true, changeOrigin: true,
// }, },
// }, },
mako: {}, mako: {},
/** /**
* @name 详细的代理配置 * @name 详细的代理配置

View File

@ -11,6 +11,8 @@ export default [
{ name: '用户管理', icon: 'table', path: '/list/anchor', component: './table-list/anchor' }, { name: '用户管理', icon: 'table', path: '/list/anchor', component: './table-list/anchor' },
{ name: '主播报单管理', icon: 'table', path: '/list/declaration', component: './table-list/declaration' }, { name: '主播报单管理', icon: 'table', path: '/list/declaration', component: './table-list/declaration' },
{ name: '模卡管理', icon: 'table', path: '/list/resume', component: './table-list/material' }, { name: '模卡管理', icon: 'table', path: '/list/resume', component: './table-list/material' },
{ name: '运营二维码管理', icon: 'table', path: '/list/staff-qrcode', component: './table-list/staff-qrcode' },
{ name: '城市运营管理', icon: 'table', path: '/list/city-operation', component: './table-list/city-operation' },
{ path: '/', redirect: '/list/job' }, { path: '/', redirect: '/list/job' },
{ path: '*', layout: false, component: './404' }, { path: '*', layout: false, component: './404' },
]; ];

View File

@ -117,7 +117,7 @@
<ul> <ul>
<li>用户违反本协议约定的,平台可采取封号等方式限制用户使用,且所付服务费用不予退还。</li> <li>用户违反本协议约定的,平台可采取封号等方式限制用户使用,且所付服务费用不予退还。</li>
<li>用户违反本协议约定,给平台或其他用户造成损失的,应承担赔偿责任。</li> <li>用户违反本协议约定,给平台或其他用户造成损失的,应承担赔偿责任。</li>
<li>用户对平台服务不满,在未违背平台用户协议的情况下,可在付款后 3 日内申请退款,平台将在扣除已使用服务标准金额后,进行退款。超出 3 日,不予退款。</li> <li>用户对平台服务不满,在未违背平台用户协议的情况下,可在付款后 3 日内申请退款,平台将在扣除已使用服务标准金额每日7元不足一日按一日算后,进行退款。超出 3 日,不予退款。</li>
</ul> </ul>
<p>退款申请渠道:<b>xiaochengxu@neighbourhood.com.cn</b></p> <p>退款申请渠道:<b>xiaochengxu@neighbourhood.com.cn</b></p>

View File

@ -96,5 +96,6 @@
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
} },
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
} }

View File

@ -25,4 +25,16 @@ export enum AdminAPI {
// 模卡 // 模卡
MATERIAL_LIST = '/api/bo/resume/list', MATERIAL_LIST = '/api/bo/resume/list',
MATERIAL_UPDATE = '/api/bo/resume/update', MATERIAL_UPDATE = '/api/bo/resume/update',
// 七牛
UPLOAD_FILE = '/api/backend/file/upload',
// 运营人员
STAFF_LIST = '/api/staff/getStaffPageList',
STAFF_ALL = '/api/staff/getAllStaff',
STAFF_UPDATE = '/api/staff/addOrEdit',
STAFF_DELETE = '/api/staff/{id}',
STAFF_SET_DEFAULT = '/api/staff/setAsDefault/{id}',
// 城市运营
CITY_OPERATOR_LIST = '/api/cityOperator/getStaffPageList',
CITY_OPERATOR_UPDATE = '/api/cityOperator/addOrEdit',
CITY_OPERATOR_DELETE = '/api/cityOperator/{id}',
} }

View File

@ -0,0 +1,247 @@
import { PlusOutlined } from '@ant-design/icons';
import {
ActionType,
ProColumns,
ProFormInstance,
ProFormSelect,
ProFormText,
ProFormDigit,
ProFormMoney,
} from '@ant-design/pro-components';
import { ModalForm, PageContainer, ProTable } from '@ant-design/pro-components';
import '@umijs/max';
import { Button, message } from 'antd';
import { Select } from 'antd';
import { createStyles } from 'antd-style';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { CITY_OPTIONS } from '@/constants/city';
import { TIME_FORMAT } from '@/constants/global';
import { deleteCityOperator, getAllStaffList, getCityOpratorList, updateCityOperator } from '@/services/list';
const useStyles = createStyles(({ token }) => {
return {
img: {
width: 100,
height: 100,
},
delete: {
color: token.colorError,
},
button: {
marginRight: '8px',
},
};
});
const TableList: React.FC = () => {
const [updateModalOpen, handleUpdateModalOpen] = useState<boolean>(false);
const [currentRow, setCurrentRow] = useState<API.CityOperatorListItem>();
const actionRef = useRef<ActionType>();
const formRef = useRef<ProFormInstance>();
const [staffOptions, setStaffOptions] = useState<Array<{ label: string; value: number; isDefault: boolean }>>([]);
const defaultStaff = useMemo(() => {
return staffOptions.find(it => it.isDefault) || staffOptions[0];
}, [staffOptions]);
const { styles } = useStyles();
const getAllStaffOptions = async () => {
try {
const results = await getAllStaffList();
setStaffOptions(
results.map(it => ({
label: `${it.staffName}${it.isDefault ? ' (默认)' : ''}`,
isDefault: Boolean(it.isDefault),
value: it.id,
})),
);
} catch (e) {
} finally {
}
};
const handleDelete = async (id: number) => {
await deleteCityOperator(id);
message.success('操作成功');
actionRef.current?.reload();
};
useEffect(() => {
getAllStaffOptions();
}, []);
const columns: ProColumns<API.CityOperatorListItem>[] = [
{
title: '城市',
dataIndex: 'cityCode',
valueType: 'textarea',
render: (_text, record) => record.cityName,
renderFormItem() {
return (
<Select
showSearch
allowClear
options={CITY_OPTIONS}
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
);
},
},
{
title: '运营',
dataIndex: 'staffName',
valueType: 'textarea',
search: false,
},
{
title: '创建时间',
dataIndex: 'created',
valueType: 'dateTime',
renderText(created: string) {
return dayjs(created).format(TIME_FORMAT);
},
search: false,
},
{
title: '进群链接',
dataIndex: 'groupLink',
valueType: 'textarea',
search: false,
},
{
title: '可群发数量',
dataIndex: 'sendCount',
valueType: 'textarea',
search: false,
},
{
title: '群代发价格',
dataIndex: 'price',
valueType: 'textarea',
search: false,
renderText: cents => (cents ? (cents / 100).toFixed(0) : '-'),
},
{
title: '操作',
valueType: 'option',
render: (_, record) => (
<>
<a
key="config"
className={styles.button}
onClick={() => {
handleUpdateModalOpen(true);
setCurrentRow(record);
formRef.current?.setFieldsValue({
...record,
city: {
label: record.cityName,
value: record.cityCode,
},
});
}}
>
</a>
<a key="delete" className={styles.delete} onClick={() => handleDelete(record.id)}>
</a>
</>
),
},
];
return (
<PageContainer>
<ProTable<API.CityOperatorListItem, API.PageParams>
headerTitle="查询表格"
actionRef={actionRef}
rowKey="key"
search={{ labelWidth: 120, collapsed: false, collapseRender: false }}
request={getCityOpratorList}
columns={columns}
toolBarRender={() => [
<Button
type="primary"
key="add"
icon={<PlusOutlined />}
onClick={() => {
handleUpdateModalOpen(true);
setCurrentRow(undefined);
formRef.current?.resetFields();
}}
>
</Button>,
]}
/>
<ModalForm
title={`${currentRow ? '修改' : '新增'}城市`}
width="400px"
formRef={formRef}
initialValues={{ staffId: defaultStaff?.value }}
open={updateModalOpen}
onOpenChange={handleUpdateModalOpen}
onFinish={async formData => {
const params: API.UpdateCityOperator = {
id: currentRow?.id,
staffId: formData.staffId,
cityCode: formData.city.value,
cityName: formData.city.label,
groupLink: formData.groupLink,
sendCount: formData.sendCount,
price: formData.price,
};
console.log('update confirm', formData, params);
try {
await updateCityOperator(params);
actionRef.current?.reload();
formRef.current?.resetFields();
} catch (e) {}
handleUpdateModalOpen(false);
}}
>
<ProFormSelect.SearchSelect
name="city"
mode="single"
label="所属城市"
options={CITY_OPTIONS}
rules={[{ required: true, message: '必填项' }]}
/>
<ProFormSelect
mode="single"
name="staffId"
showSearch
label="运营"
options={staffOptions}
rules={[{ required: true, message: '必填项' }]}
/>
<ProFormText name="groupLink" label="进群链接" rules={[{ message: '请输入链接', type: 'url' }]} />
<ProFormDigit name="sendCount" label="可群发数量" min={1} fieldProps={{ precision: 0 }} />
<ProFormMoney
name="price"
label="价格"
min={0}
fieldProps={{
precision: 0,
formatter: value => {
if (!value) return '0';
return `${Math.floor(value / 100)}`;
},
parser: value => {
if (!value) return 0;
const numValue = parseInt(value.toString().replace(/[^\d]/g, ''));
return numValue * 100;
},
}}
transform={(value, namePath) => {
return {
[namePath]: value ? Math.round(Number(value)) : 0,
};
}}
/>
</ModalForm>
</PageContainer>
);
};
export default TableList;

View File

@ -92,6 +92,18 @@ const TableList: React.FC = () => {
valueType: 'textarea', valueType: 'textarea',
copyable: true, copyable: true,
}, },
{
title: '微信号',
dataIndex: 'acctNo',
valueType: 'textarea',
copyable: true,
},
{
title: '手机号',
dataIndex: 'phone',
valueType: 'textarea',
copyable: true,
},
{ {
title: '发布群数量', title: '发布群数量',
dataIndex: 'relateGroupCount', dataIndex: 'relateGroupCount',

View File

@ -30,6 +30,11 @@ const HAS_DECLARE_OPTIONS = [
{ label: '无报单', value: false }, { label: '无报单', value: false },
]; ];
const PRIORITY_OPTIONS = [
{ label: '优先处理', value: 1 },
{ label: '正常处理', value: 2 },
];
const TableList: React.FC = () => { const TableList: React.FC = () => {
const [updateModalOpen, handleUpdateModalOpen] = useState<boolean>(false); const [updateModalOpen, handleUpdateModalOpen] = useState<boolean>(false);
const [currentRow, setCurrentRow] = useState<API.PublisherListItem>(); const [currentRow, setCurrentRow] = useState<API.PublisherListItem>();
@ -49,6 +54,16 @@ const TableList: React.FC = () => {
valueType: 'textarea', valueType: 'textarea',
copyable: true, copyable: true,
}, },
{
title: '优先级',
dataIndex: 'priority',
renderText(status: number) {
return PRIORITY_OPTIONS.find(option => option.value === status)?.label;
},
renderFormItem() {
return <Select showSearch allowClear options={PRIORITY_OPTIONS} />;
},
},
{ {
title: '账号状态', title: '账号状态',
dataIndex: 'status', dataIndex: 'status',

View File

@ -0,0 +1,185 @@
import { PlusOutlined } from '@ant-design/icons';
import { ActionType, ProColumns, ProFormInstance, ProFormText, ProFormUploadButton } from '@ant-design/pro-components';
import { ModalForm, PageContainer, ProTable } from '@ant-design/pro-components';
import '@umijs/max';
import { Button, message } from 'antd';
import { createStyles } from 'antd-style';
import dayjs from 'dayjs';
import React, { useRef, useState } from 'react';
import { TIME_FORMAT } from '@/constants/global';
import { uploadFile } from '@/services/file';
import { deleteStaff, getStaffList, setDefaultStaff, updateStaff } from '@/services/list';
const useStyles = createStyles(({ token }) => {
return {
img: {
width: 100,
height: 100,
},
delete: {
color: token.colorError,
},
button: {
marginRight: '8px',
},
};
});
const TableList: React.FC = () => {
const [updateModalOpen, handleUpdateModalOpen] = useState<boolean>(false);
const [currentRow, setCurrentRow] = useState<API.StaffListItem>();
const actionRef = useRef<ActionType>();
const formRef = useRef<ProFormInstance>();
const { styles } = useStyles();
const handleDefault = async (id: number) => {
await setDefaultStaff(id);
actionRef.current?.reload();
message.success('操作成功');
};
const handleDelete = async (id: number) => {
await deleteStaff(id);
actionRef.current?.reload();
message.success('操作成功');
};
const handleUpload = async (file: File) => {
const { url } = await uploadFile({ file, type: 'IMAGE' });
return url;
};
const columns: ProColumns<API.StaffListItem>[] = [
{
title: '昵称',
dataIndex: 'staffName',
valueType: 'textarea',
},
{
title: '二维码',
dataIndex: 'staffQrCode',
valueType: 'textarea',
copyable: true,
search: false,
render(_dom, { staffQrCode }) {
return <img className={styles.img} src={staffQrCode} alt="" />;
},
},
{
title: '默认',
dataIndex: 'isDefault',
valueType: 'textarea',
search: false,
renderText(isDefault) {
return isDefault ? '是' : '否';
},
},
{
title: '添加时间',
dataIndex: 'created',
valueType: 'dateTime',
renderText(created: string) {
return dayjs(created).format(TIME_FORMAT);
},
search: false,
},
{
title: '操作',
valueType: 'option',
render: (_, record) => (
<>
<a
key="config"
className={styles.button}
onClick={() => {
handleUpdateModalOpen(true);
setCurrentRow(record);
formRef.current?.setFieldsValue({
...record,
qrCode: [
{
uid: `${record.id}_qrCode`,
name: `${record.staffName}_qrCode`,
status: 'done',
url: record.staffQrCode,
},
],
});
}}
>
</a>
<a key="default" className={styles.button} onClick={() => handleDefault(record.id)}>
</a>
<a key="delete" className={styles.delete} onClick={() => handleDelete(record.id)}>
</a>
</>
),
},
];
return (
<PageContainer>
<ProTable<API.StaffListItem, API.PageParams>
headerTitle="查询表格"
actionRef={actionRef}
rowKey="key"
search={{ labelWidth: 120, collapsed: false, collapseRender: false }}
request={getStaffList}
columns={columns}
toolBarRender={() => [
<Button
type="primary"
key="add"
icon={<PlusOutlined />}
onClick={() => {
handleUpdateModalOpen(true);
setCurrentRow(undefined);
formRef.current?.resetFields();
}}
>
</Button>,
]}
/>
<ModalForm
title={`${currentRow ? '修改' : '新增'}运营`}
width="400px"
formRef={formRef}
open={updateModalOpen}
onOpenChange={handleUpdateModalOpen}
onFinish={async formData => {
const params: API.UpdateStaffParams = {
id: currentRow?.id,
isDefault: currentRow?.isDefault || 0,
staffName: formData?.staffName,
staffQrCode: formData.qrCode[0].xhr.responseURL,
};
console.log('update confirm', formData, params);
try {
await updateStaff(params);
actionRef.current?.reload();
formRef.current?.resetFields();
} catch (e) {}
handleUpdateModalOpen(false);
}}
>
<ProFormText name="staffName" label="昵称" rules={[{ required: true, message: '必填项' }]} />
<ProFormUploadButton
name="qrCode"
label="上传"
max={1}
accept="image/*"
rules={[{ required: true, message: '必填项' }]}
fieldProps={{
name: 'file',
}}
action={handleUpload}
/>
</ModalForm>
</PageContainer>
);
};
export default TableList;

View File

@ -7,14 +7,11 @@ import { clearToken, getToken, gotoLogin } from '@/utils/login';
import { IRequestResponse } from './types/http'; import { IRequestResponse } from './types/http';
/** /**
* @name 全局请求配置 * @name 全局请求配置
* @doc https://umijs.org/docs/max/request#配置 * @doc https://umijs.org/docs/max/request#配置
*/ */
export const requestConfig: RequestConfig = { export const requestConfig: RequestConfig = {
baseURL: (window.ENV?.BASE_URL || 'https://neighbourhood.cn') as string, baseURL: (window.ENV?.BASE_URL || 'https://neighbourhood.cn') as string,
// 错误处理: umi@3 的错误处理方案。 // 错误处理: umi@3 的错误处理方案。
errorConfig: { errorConfig: {

13
src/services/file.ts Normal file
View File

@ -0,0 +1,13 @@
import { request } from '@@/exports';
import { AdminAPI } from '@/constants/api';
export async function uploadFile(options: { file: File; type: 'VIDEO' | 'IMAGE' }): Promise<API.UploadFile> {
return request<API.UploadFile>(AdminAPI.UPLOAD_FILE, {
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data',
},
data: options,
});
}

View File

@ -4,6 +4,7 @@ import { AdminAPI } from '@/constants/api';
import { EmployType, JobType } from '@/constants/job'; import { EmployType, JobType } from '@/constants/job';
import { request } from '@umijs/max'; import { request } from '@umijs/max';
import { SortOrder } from 'antd/es/table/interface'; import { SortOrder } from 'antd/es/table/interface';
import { buildUrl } from '@/services/utils';
function transformPageParams(params: API.PageParams & Record<string, any>) { function transformPageParams(params: API.PageParams & Record<string, any>) {
params.page = params.current; params.page = params.current;
@ -16,6 +17,10 @@ function transformPageParams(params: API.PageParams & Record<string, any>) {
return params; return params;
} }
function camelToSnakeCase(str: string) {
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1_$2').toLowerCase();
}
function transformSort(sort: Record<string, SortOrder>) { function transformSort(sort: Record<string, SortOrder>) {
if (!sort) { if (!sort) {
return {}; return {};
@ -25,7 +30,7 @@ function transformSort(sort: Record<string, SortOrder>) {
return {}; return {};
} }
const asc = sort[sortField] === 'ascend'; const asc = sort[sortField] === 'ascend';
return { sortField, asc }; return { sortField: camelToSnakeCase(sortField), asc };
} }
function sortTableList<T extends { created: number; updated: number }>( function sortTableList<T extends { created: number; updated: number }>(
@ -43,9 +48,12 @@ function sortTableList<T extends { created: number; updated: number }>(
return response; return response;
} }
export async function getJobList(params: API.PageParams & Partial<API.JobListItem>, options?: { export async function getJobList(
[key: string]: any params: API.PageParams & Partial<API.JobListItem>,
}) { options?: {
[key: string]: any;
},
) {
if (!params.category) { if (!params.category) {
params.category = JobType.All; params.category = JobType.All;
} }
@ -222,3 +230,71 @@ export async function updateDeclarationInfo(options: API.UpdateDeclarationParams
}, },
}); });
} }
export async function getStaffList(
params: API.PageParams & Partial<API.StaffListItem>,
options?: { [key: string]: any },
) {
const result = await request<API.TableList<API.StaffListItem>>(AdminAPI.STAFF_LIST, {
method: 'GET',
params: {
...transformPageParams(params),
...(options || {}),
},
});
return result;
}
export async function getAllStaffList() {
return await request<API.StaffListItem[]>(AdminAPI.STAFF_ALL, { method: 'GET' });
}
export async function updateStaff(options: API.UpdateStaffParams) {
return request(AdminAPI.STAFF_UPDATE, {
method: 'POST',
data: {
...(options || {}),
},
});
}
export async function deleteStaff(id: number) {
return request(buildUrl(AdminAPI.STAFF_DELETE, { id }), {
method: 'DELETE',
});
}
export async function setDefaultStaff(id: number) {
return request(buildUrl(AdminAPI.STAFF_SET_DEFAULT, { id }), {
method: 'PUT',
});
}
export async function getCityOpratorList(
params: API.PageParams & Partial<API.CityOperatorListItem>,
options?: { [key: string]: any },
) {
const result = await request<API.TableList<API.CityOperatorListItem>>(AdminAPI.CITY_OPERATOR_LIST, {
method: 'GET',
params: {
...transformPageParams(params),
...(options || {}),
},
});
return result;
}
export async function updateCityOperator(options: API.UpdateCityOperator) {
return request<API.GroupListItem>(AdminAPI.CITY_OPERATOR_UPDATE, {
method: 'POST',
data: {
...(options || {}),
},
});
}
export async function deleteCityOperator(id: number) {
return request(buildUrl(AdminAPI.CITY_OPERATOR_DELETE, { id }), {
method: 'DELETE',
});
}

View File

@ -74,6 +74,14 @@ declare namespace API {
updated: string; updated: string;
// 是否禁用,默认为 false // 是否禁用,默认为 false
disable: boolean; disable: boolean;
/**
* 通告手机号
*/
phone: string;
/**
* 通告微信号
*/
acctNo: string;
} }
interface GroupListItem { interface GroupListItem {
@ -322,4 +330,54 @@ declare namespace API {
type UpdateDeclarationParams = Pick<DeclarationListItem, 'id' | 'weComStatus'>; type UpdateDeclarationParams = Pick<DeclarationListItem, 'id' | 'weComStatus'>;
type UpdateMaterialParams = Pick<MaterialListItem, 'id'> & { adminOpen: number }; type UpdateMaterialParams = Pick<MaterialListItem, 'id'> & { adminOpen: number };
interface UploadFile {
id: string;
url: string;
coverUrl: string;
}
interface StaffListItem {
id: number;
staffName: string;
staffQrCode: string;
isDefault: 1 | 0;
created: string;
updated: string;
}
interface UpdateStaffParams {
id?: number;
staffName: string;
staffQrCode: string;
isDefault: 1 | 0;
created?: string;
updated?: string;
}
interface CityOperatorListItem {
cityName: string;
staffName: string;
created: string;
updated: string;
staffId: number;
cityCode: string;
groupLink: string;
sendCount: number;
price: number;
id: number;
}
interface UpdateCityOperator {
id?: number;
staffId: number;
staffName?: string;
cityName?: string;
cityCode: string;
groupLink: string;
sendCount?: number;
price?: number;
created?: string;
updated?: string;
}
} }

5
src/services/utils.ts Normal file
View File

@ -0,0 +1,5 @@
export function buildUrl(url: string, params: Record<string, string | number>): string {
return Object.entries(params).reduce((result, [key, value]) => {
return result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
}, url);
}