diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
index 4555702..9b68121 100644
--- a/apps/admin-web/src/App.tsx
+++ b/apps/admin-web/src/App.tsx
@@ -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 ;
@@ -41,6 +42,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/admin-web/src/components/OssUpload.tsx b/apps/admin-web/src/components/OssUpload.tsx
new file mode 100644
index 0000000..7196b8e
--- /dev/null
+++ b/apps/admin-web/src/components/OssUpload.tsx
@@ -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 (
+
+ {value && mediaType === 'IMAGE' && (
+
+ )}
+ {value && mediaType === 'VIDEO' && (
+
+ )}
+
+
+ } loading={uploading}>
+ {mediaType === 'VIDEO' ? '上传视频' : mediaType === 'FILE' ? '上传文件' : '上传图片'}
+
+
+
+ onChange?.(e.target.value)}
+ allowClear
+ />
+
+ );
+}
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index cb88539..e9541f8 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -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: , label: '用户' },
{ key: '/products', icon: , label: '商品' },
{ key: '/orders', icon: , label: '订单' },
+ { key: '/resources', icon: , label: 'OSS 资源库' },
{
key: 'stores-group',
icon: ,
diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts
index b716ee5..53cd729 100644
--- a/apps/admin-web/src/lib/constants.ts
+++ b/apps/admin-web/src/lib/constants.ts
@@ -42,6 +42,33 @@ export const PARTNER_BILL_STATUS_LABELS: Record = {
export const MEDIA_TYPE_LABELS: Record = {
IMAGE: '图片',
VIDEO: '视频',
+ FILE: '文件',
+};
+
+export const RESOURCE_OWNER_TYPE_LABELS: Record = {
+ PRODUCT: '商品',
+ STORE: '门店',
+ PARTNER: '合伙人',
+ USER: '用户',
+ ORDER: '订单',
+ HQ: '总部',
+};
+
+export const RESOURCE_BIZ_TYPE_LABELS: Record = {
+ COVER: '封面',
+ ENV: '环境图',
+ CONTRACT: '合同',
+ CAROUSEL: '轮播',
+ DETAIL: '详情',
+ AVATAR: '头像',
+ QRCODE: '二维码',
+ SIGN_PHOTO: '签收照',
+ VIDEO: '视频',
+};
+
+export const RESOURCE_STATUS_LABELS: Record = {
+ ACTIVE: '有效',
+ DELETED: '已删除',
};
export const PRODUCT_STATUS_LABELS: Record = {
diff --git a/apps/admin-web/src/lib/upload.ts b/apps/admin-web/src/lib/upload.ts
new file mode 100644
index 0000000..5cf1c05
--- /dev/null
+++ b/apps/admin-web/src/lib/upload.ts
@@ -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 {
+ 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 = {
+ '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,
+ };
+}
diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx
index 6f996b1..117c7ea 100644
--- a/apps/admin-web/src/pages/ProductsPage.tsx
+++ b/apps/admin-web/src/pages/ProductsPage.tsx
@@ -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() {