web端打通oss资源上传

This commit is contained in:
2026-07-01 21:46:36 +08:00
parent ef6cc18fb2
commit 139e37651b
22 changed files with 1186 additions and 40 deletions
+2
View File
@@ -17,6 +17,7 @@ import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import StoreMediaPage from './pages/StoreMediaPage';
import ProductsPage from './pages/ProductsPage';
import ResourcesPage from './pages/ResourcesPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
@@ -41,6 +42,7 @@ export default function App() {
<Route path="/stores" element={<StoresPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/partners" element={<PartnersPage />} />
<Route path="/cities" element={<CitiesPage />} />
<Route path="/partner-accounts" element={<PartnerAccountsPage />} />
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
type OssUploadProps = {
value?: string;
onChange?: (url: string) => void;
onUploaded?: (result: UploadFileResult) => void;
bizType: string;
mediaType?: OssMediaType;
accept?: string;
maxSizeMb?: number;
placeholder?: string;
};
const DEFAULT_MAX_MB = 10;
export default function OssUpload({
value,
onChange,
onUploaded,
bizType,
mediaType = 'IMAGE',
accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true);
try {
const result = await uploadFileToOss(raw, { bizType, mediaType });
onChange?.(result.url);
onUploaded?.(result);
if (result.mock) {
message.info('当前为 Mock OSS,已使用占位 URL');
} else {
message.success('上传成功');
}
onSuccess?.(result);
} catch (e) {
const err = e instanceof Error ? e : new Error('上传失败');
message.error(err.message);
onError?.(err);
} finally {
setUploading(false);
}
};
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
)}
{value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)}
<Space wrap>
<Upload
accept={resolvedAccept}
showUploadList={false}
customRequest={customRequest}
disabled={uploading}
>
<Button icon={<UploadOutlined />} loading={uploading}>
{mediaType === 'VIDEO' ? '上传视频' : mediaType === 'FILE' ? '上传文件' : '上传图片'}
</Button>
</Upload>
</Space>
<Input
value={value}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
allowClear
/>
</Space>
);
}
@@ -12,6 +12,7 @@ import {
CarOutlined,
SafetyOutlined,
LogoutOutlined,
CloudUploadOutlined,
} from '@ant-design/icons';
import { clearAuth, request, type HqProfile } from '../lib/api';
@@ -22,6 +23,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/users', icon: <UserOutlined />, label: '用户' },
{ key: '/products', icon: <ShoppingOutlined />, label: '商品' },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{
key: 'stores-group',
icon: <ShopOutlined />,
+27
View File
@@ -42,6 +42,33 @@ export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
export const MEDIA_TYPE_LABELS: Record<string, string> = {
IMAGE: '图片',
VIDEO: '视频',
FILE: '文件',
};
export const RESOURCE_OWNER_TYPE_LABELS: Record<string, string> = {
PRODUCT: '商品',
STORE: '门店',
PARTNER: '合伙人',
USER: '用户',
ORDER: '订单',
HQ: '总部',
};
export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
COVER: '封面',
ENV: '环境图',
CONTRACT: '合同',
CAROUSEL: '轮播',
DETAIL: '详情',
AVATAR: '头像',
QRCODE: '二维码',
SIGN_PHOTO: '签收照',
VIDEO: '视频',
};
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
ACTIVE: '有效',
DELETED: '已删除',
};
export const PRODUCT_STATUS_LABELS: Record<string, string> = {
+49
View File
@@ -0,0 +1,49 @@
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
export type UploadFileResult = {
url: string;
ossKey: string;
bucket: string;
mock: boolean;
};
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export async function uploadFileToOss(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
const headers: Record<string, string> = {
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
});
const json = await res.json();
if (json.code === 401) {
clearAuth();
window.location.href = '/login';
throw new Error('未登录');
}
if (json.code !== 0) throw new Error(json.message || '上传失败');
const data = json.data as UploadFileResult;
return {
url: data.url,
ossKey: data.ossKey,
bucket: data.bucket,
mock: data.mock,
};
}
+7 -2
View File
@@ -6,6 +6,7 @@ import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type Row = {
id: string;
@@ -114,7 +115,9 @@ export default function ProductsPage() {
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="coverUrl" label="封面 URL"><Input placeholder="https://..." /></Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
</Form>
</>
)}
@@ -142,7 +145,9 @@ export default function ProductsPage() {
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="coverUrl" label="封面 URL"><Input placeholder="https://..." /></Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
</Form>
</Modal>
</div>
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useState } from 'react';
import {
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import {
MEDIA_TYPE_LABELS,
RESOURCE_BIZ_TYPE_LABELS,
RESOURCE_OWNER_TYPE_LABELS,
RESOURCE_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import OssUpload from '../components/OssUpload';
type Row = {
id: string;
ownerType: string;
ownerId: string;
bizType: string;
mediaType: string;
ossBucket: string;
ossKey: string;
url: string;
fileName?: string | null;
sortOrder: number;
status: string;
createdAt: string;
};
export default function ResourcesPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [data, setData] = useState<Paginated<Row> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
async function reload(p = page, ps = pageSize, f = filters) {
setLoading(true);
try {
const qs = new URLSearchParams({ page: String(p), pageSize: String(ps) });
if (f.ownerType) qs.set('ownerType', f.ownerType);
if (f.ownerId) qs.set('ownerId', f.ownerId);
if (f.bizType) qs.set('bizType', f.bizType);
if (f.status) qs.set('status', f.status);
const res = await request<Paginated<Row>>(`/common/resources?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, []);
const columns: ColumnsType<Row> = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{
title: '归属',
width: 140,
render: (_, row) => (
<span>
{RESOURCE_OWNER_TYPE_LABELS[row.ownerType] || row.ownerType} / {row.ownerId}
</span>
),
},
{ title: '用途', dataIndex: 'bizType', width: 90, render: (v) => RESOURCE_BIZ_TYPE_LABELS[v] || v },
{ title: '类型', dataIndex: 'mediaType', width: 70, render: (v) => MEDIA_TYPE_LABELS[v] || v },
{
title: '预览',
dataIndex: 'url',
width: 90,
render: (url, row) =>
row.mediaType === 'IMAGE' ? (
<Image src={url} width={56} height={40} style={{ objectFit: 'cover' }} />
) : (
<a href={url} target="_blank" rel="noreferrer">
</a>
),
},
{ title: 'URL', dataIndex: 'url', ellipsis: true },
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{RESOURCE_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Popconfirm
title="确认删除该资源?"
onConfirm={async () => {
await request(`/common/resources/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
OSS
</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
void reload(1, pageSize, v);
}}
>
<Form.Item name="ownerType" label="归属类型">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="ownerId" label="归属 ID">
<Input allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="bizType" label="用途">
<Select
allowClear
style={{ width: 110 }}
options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(RESOURCE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
void reload(p, ps);
},
}}
/>
<Modal
title="登记 OSS 资源"
open={createOpen}
confirmLoading={saving}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
setSaving(true);
try {
await request('/common/resources', {
method: 'POST',
body: JSON.stringify({
ownerType: v.ownerType,
ownerId: v.ownerId,
bizType: v.bizType,
mediaType: v.mediaType,
ossKey: v.ossKey,
url: v.url,
fileName: v.fileName,
sortOrder: v.sortOrder ?? 0,
}),
});
message.success('已登记');
setCreateOpen(false);
createForm.resetFields();
void reload();
} finally {
setSaving(false);
}
}}
width={520}
>
<Form form={createForm} layout="vertical" initialValues={{ mediaType: 'IMAGE', bizType: 'COVER', sortOrder: 0 }}>
<Form.Item name="ownerType" label="归属类型" rules={[{ required: true }]}>
<Select options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="ownerId" label="归属 ID" rules={[{ required: true }]}>
<Input placeholder="商品/门店等业务 ID" />
</Form.Item>
<Form.Item name="bizType" label="用途" rules={[{ required: true }]}>
<Select options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="mediaType" label="媒体类型" rules={[{ required: true }]}>
<Select
options={[
...Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label })),
{ value: 'FILE', label: '文件' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType || prev.bizType !== cur.bizType}>
{({ getFieldValue }) => {
const mediaType = getFieldValue('mediaType') || 'IMAGE';
const bizType = getFieldValue('bizType') || 'COVER';
return (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload
bizType={bizType}
mediaType={mediaType}
onUploaded={(result) => {
createForm.setFieldsValue({ ossKey: result.ossKey, url: result.url });
}}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="ossKey" label="OSS Key" rules={[{ required: true }]}>
<Input placeholder="上传后自动填入" />
</Form.Item>
<Form.Item name="fileName" label="文件名">
<Input />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+15 -2
View File
@@ -6,6 +6,7 @@ import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type Row = {
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
@@ -102,7 +103,13 @@ export default function StoreMediaPage() {
<Form.Item name="mediaType" label="类型" rules={[{ required: true }]} initialValue="IMAGE">
<Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="url" label="资源 URL" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
{({ getFieldValue }) => (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload bizType="ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
</Form.Item>
)}
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
@@ -116,7 +123,13 @@ export default function StoreMediaPage() {
}}>
<Form form={editForm} layout="vertical">
<Form.Item name="mediaType" label="类型"><Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} /></Form.Item>
<Form.Item name="url" label="资源 URL" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
{({ getFieldValue }) => (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload bizType="ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
</Form.Item>
)}
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
+9 -4
View File
@@ -6,6 +6,7 @@ import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type StoreRow = {
id: string;
@@ -128,16 +129,18 @@ export default function StoresPage() {
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
{detail.coverUrl && (
{detail.coverUrl ? (
<Descriptions.Item label="封面">
<Image src={String(detail.coverUrl)} width={120} />
</Descriptions.Item>
)}
) : null}
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面图 URL"><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面图">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
<Form.Item name="district" label="区县"><Input /></Form.Item>
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
@@ -164,7 +167,9 @@ export default function StoresPage() {
<Form.Item name="phone" label="门店电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="district" label="区县"><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面图 URL"><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面图">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
<Form.Item name="intro" label="介绍"><Input.TextArea rows={3} /></Form.Item>
<Form.Item name="accountPhone" label="店长手机"><Input placeholder="默认同门店电话" /></Form.Item>
<Form.Item name="accountName" label="店长姓名"><Input placeholder="默认同门店名" /></Form.Item>