feat(project): align category manager with prototype and clean drawer implementation

This commit is contained in:
2026-02-20 17:57:20 +08:00
parent 788491ad3d
commit c6d0694737
10 changed files with 630 additions and 451 deletions

View File

@@ -6,6 +6,10 @@ import type { ProductPreviewItem } from '../types';
*/
import type { ProductCategoryManageDto } from '#/api/product';
import { IconifyIcon } from '@vben/icons';
import { Button } from 'ant-design-vue';
import {
CATEGORY_CHANNEL_META,
CATEGORY_CHANNEL_ORDER,
@@ -29,6 +33,17 @@ interface Emits {
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function isImageUrl(value: string) {
const source = value.trim();
if (!source) return false;
return (
source.startsWith('data:image/') ||
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('/')
);
}
</script>
<template>
@@ -43,16 +58,22 @@ const emit = defineEmits<Emits>();
</div>
</div>
<div class="pcat-info-actions">
<button type="button" class="pcat-btn" @click="emit('edit')">
<Button class="pcat-action-btn" @click="emit('edit')">
<template #icon>
<IconifyIcon icon="lucide:pencil" />
</template>
编辑
</button>
<button
type="button"
class="pcat-btn pcat-btn-danger"
</Button>
<Button
danger
class="pcat-action-btn pcat-action-btn-danger"
@click="emit('remove')"
>
<template #icon>
<IconifyIcon icon="lucide:trash-2" />
</template>
删除
</button>
</Button>
</div>
</div>
@@ -91,7 +112,13 @@ const emit = defineEmits<Emits>();
<div class="pcat-attr-item">
<span class="pcat-attr-label">分类图标</span>
<span class="pcat-attr-value icon-value">
{{ props.selectedCategory.icon || '未设置' }}
<img
v-if="isImageUrl(props.selectedCategory.icon)"
:src="props.selectedCategory.icon"
class="pcat-category-icon-preview"
alt="分类图标"
/>
<span v-else class="pcat-category-icon-empty">未设置</span>
</span>
</div>
</div>
@@ -109,7 +136,10 @@ const emit = defineEmits<Emits>();
class="pcat-ch-icon"
:class="CATEGORY_CHANNEL_META[channel].cardClass"
>
{{ CATEGORY_CHANNEL_META[channel].shortLabel }}
<IconifyIcon
:icon="CATEGORY_CHANNEL_META[channel].icon"
class="pcat-ch-icon-svg"
/>
</div>
<div class="pcat-ch-info">
<div class="pcat-ch-name">
@@ -142,13 +172,12 @@ const emit = defineEmits<Emits>();
分类商品
<span>{{ props.categoryProducts.length }} 个商品</span>
</div>
<button
type="button"
class="pcat-btn pcat-btn-sm"
@click="emit('openPicker')"
>
<Button class="pcat-add-product-btn" @click="emit('openPicker')">
<template #icon>
<IconifyIcon icon="lucide:plus" />
</template>
添加商品到此分类
</button>
</Button>
</div>
<table class="pcat-prod-table">
<thead>
@@ -170,7 +199,19 @@ const emit = defineEmits<Emits>();
<tr v-for="item in props.categoryProducts" v-else :key="item.id">
<td>
<div class="pcat-prod-name">
<div class="pcat-prod-thumb"></div>
<div class="pcat-prod-thumb">
<img
v-if="item.imageUrl"
:src="item.imageUrl"
:alt="item.name"
class="pcat-prod-thumb-img"
/>
<IconifyIcon
v-else
icon="lucide:image"
class="pcat-prod-thumb-icon"
/>
</div>
<div class="pcat-prod-info">
<div class="name">{{ item.name }}</div>
<div class="spu">{{ item.spuCode }}</div>

View File

@@ -11,7 +11,18 @@ import { computed, onBeforeUnmount, ref } from 'vue';
import { VCropper } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { message, Modal, Upload } from 'ant-design-vue';
import {
Button,
Drawer,
Form,
FormItem,
Input,
InputNumber,
message,
Modal,
Switch,
Upload,
} from 'ant-design-vue';
import { uploadTenantFileApi } from '#/api/files';
@@ -38,6 +49,7 @@ interface Emits {
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const cropOpen = ref(false);
const cropConfirming = ref(false);
const cropImageUrl = ref('');
@@ -56,26 +68,22 @@ const canPreviewIcon = computed(() => {
);
});
function setName(event: Event) {
const target = event.target as HTMLInputElement | null;
emit('setName', target?.value ?? '');
function setName(value: string | undefined) {
emit('setName', value ?? '');
}
function setDescription(event: Event) {
const target = event.target as HTMLTextAreaElement | null;
emit('setDescription', target?.value ?? '');
function setDescription(value: string | undefined) {
emit('setDescription', value ?? '');
}
function setSort(event: Event) {
const target = event.target as HTMLInputElement | null;
const value = Number(target?.value ?? '0');
emit('setSort', value);
}
function getChannelIcon(channel: ProductCategoryChannel) {
if (channel === 'wm') return 'lucide:bike';
if (channel === 'pickup') return 'lucide:shopping-bag';
return 'lucide:utensils';
function setSort(value: null | number | string) {
let nextValue = 0;
if (typeof value === 'number') {
nextValue = value;
} else if (typeof value === 'string') {
nextValue = Number(value);
}
emit('setSort', nextValue);
}
function isAllowedIconFile(file: File) {
@@ -124,6 +132,7 @@ async function confirmCrop() {
120,
120,
);
if (
typeof cropResult !== 'string' ||
!cropResult.startsWith('data:image/')
@@ -173,6 +182,50 @@ function beforeUpload(file: File) {
return false;
}
function handleDrawerOpenChange(open: boolean) {
if (!open) {
emit('close');
}
}
function isChannelChecked(channel: ProductCategoryChannel) {
return props.form.channels.includes(channel);
}
function getChannelButtonStyle(channel: ProductCategoryChannel) {
if (isChannelChecked(channel)) {
return {
background: 'rgb(22 119 255 / 6%)',
borderColor: '#1677ff',
color: '#1677ff',
};
}
return {
background: '#fff',
borderColor: '#e5e7eb',
color: '#4b5563',
};
}
function getChannelCheckStyle(channel: ProductCategoryChannel) {
if (isChannelChecked(channel)) {
return {
background: '#1677ff',
borderColor: '#1677ff',
};
}
return {
background: '#fff',
borderColor: '#d1d5db',
};
}
function getChannelCheckIconStyle(channel: ProductCategoryChannel) {
return {
color: isChannelChecked(channel) ? '#fff' : 'transparent',
};
}
onBeforeUnmount(() => {
if (cropObjectUrl) {
URL.revokeObjectURL(cropObjectUrl);
@@ -182,67 +235,65 @@ onBeforeUnmount(() => {
</script>
<template>
<div
class="pcat-drawer-mask"
:class="{ open: props.open }"
@click="emit('close')"
></div>
<div class="pcat-drawer" :class="{ open: props.open }">
<div class="pcat-drawer-hd">
<span class="pcat-drawer-title">{{ props.title }}</span>
<Drawer
class="pcat-editor-drawer-wrap"
:open="props.open"
:title="props.title"
:width="480"
:closable="false"
destroy-on-close
@close="emit('close')"
@update:open="handleDrawerOpenChange"
>
<template #extra>
<button type="button" class="pcat-drawer-close" @click="emit('close')">
×
<IconifyIcon icon="lucide:x" />
</button>
</div>
<div class="pcat-drawer-bd">
<div class="pcat-form-group">
<label class="pcat-form-label required">分类名称</label>
<input
</template>
<Form layout="vertical" class="pcat-drawer-form">
<FormItem label="分类名称" required>
<Input
:value="props.form.name"
type="text"
class="pcat-input"
maxlength="20"
:maxlength="20"
placeholder="请输入分类名称,如:热销推荐"
@input="setName"
@update:value="setName"
/>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label">分类描述</label>
<textarea
</FormItem>
<FormItem label="分类描述">
<Input.TextArea
:value="props.form.description"
class="pcat-textarea"
rows="2"
maxlength="100"
:rows="2"
:maxlength="100"
placeholder="请输入分类描述,帮助顾客理解分类内容"
@input="setDescription"
></textarea>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label">分类图标</label>
<div class="pcat-icon-upload-wrap">
<Upload
:show-upload-list="false"
accept=".png,.svg,image/png,image/svg+xml"
:before-upload="beforeUpload"
>
<div class="pcat-icon-upload">
<img
v-if="canPreviewIcon"
:src="props.form.icon"
class="pcat-icon-preview"
alt="分类图标"
/>
<template v-else>
<span class="pcat-icon-upload-sign">+</span>
<span class="pcat-icon-upload-text">上传图标</span>
</template>
</div>
</Upload>
</div>
@update:value="setDescription"
/>
</FormItem>
<FormItem label="分类图标">
<Upload
:show-upload-list="false"
accept=".png,.svg,image/png,image/svg+xml"
:before-upload="beforeUpload"
>
<div class="pcat-icon-upload">
<img
v-if="canPreviewIcon"
:src="props.form.icon"
class="pcat-icon-preview"
alt="分类图标"
/>
<template v-else>
<span class="pcat-icon-plus">+</span>
<span class="pcat-icon-upload-text">上传图标</span>
</template>
</div>
</Upload>
<div class="pcat-hint">建议尺寸 120x120px支持 PNG/SVG</div>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label required">销售渠道</label>
</FormItem>
<FormItem label="销售渠道" required>
<div class="pcat-hint mb-8">选择该分类在哪些渠道展示至少选择一个</div>
<div class="pcat-drawer-channels">
<button
@@ -250,65 +301,69 @@ onBeforeUnmount(() => {
:key="`drawer-channel-${channel}`"
type="button"
class="pcat-drawer-ch"
:class="{ checked: props.form.channels.includes(channel) }"
:style="getChannelButtonStyle(channel)"
@click="emit('toggleChannel', channel)"
>
<span class="pcat-ch-check"></span>
<span class="pcat-ch-check" :style="getChannelCheckStyle(channel)">
<IconifyIcon
icon="lucide:check"
class="pcat-ch-check-icon"
:style="getChannelCheckIconStyle(channel)"
/>
</span>
<IconifyIcon
class="pcat-drawer-ch-icon"
:icon="getChannelIcon(channel)"
:icon="CATEGORY_CHANNEL_META[channel].icon"
/>
{{ CATEGORY_CHANNEL_META[channel].label }}
</button>
</div>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label">排序</label>
<input
</FormItem>
<FormItem label="排序">
<InputNumber
:value="props.form.sort"
type="number"
class="pcat-input pcat-input-small"
min="1"
class="pcat-sort-input"
:min="1"
:controls="false"
placeholder="数字越小越靠前1"
@input="setSort"
@update:value="setSort"
/>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label">启用状态</label>
</FormItem>
<FormItem label="启用状态">
<div class="pcat-toggle-row">
<button
type="button"
class="pcat-toggle"
:class="{ on: props.form.status === 'enabled' }"
@click="emit('toggleStatus')"
></button>
<Switch
:checked="props.form.status === 'enabled'"
@change="emit('toggleStatus')"
/>
<span class="pcat-toggle-label">
{{ props.form.status === 'enabled' ? '启用' : '停用' }}
</span>
</div>
</FormItem>
</Form>
<template #footer>
<div class="pcat-drawer-ft">
<Button @click="emit('close')">取消</Button>
<Button
type="primary"
:loading="props.submitting"
@click="emit('submit')"
>
{{ props.submitting ? '保存中...' : props.submitText }}
</Button>
</div>
</div>
<div class="pcat-drawer-ft">
<button type="button" class="pcat-btn" @click="emit('close')">
取消
</button>
<button
type="button"
class="pcat-btn pcat-btn-primary"
:disabled="props.submitting"
@click="emit('submit')"
>
{{ props.submitting ? '保存中...' : props.submitText }}
</button>
</div>
</div>
</template>
</Drawer>
<Modal
:open="cropOpen"
title="裁剪分类图标"
:width="560"
:mask-closable="false"
:keyboard="false"
:get-container="false"
:confirm-loading="cropConfirming"
destroy-on-close
ok-text="确认"

View File

@@ -6,6 +6,10 @@ import type { ChannelFilter } from '../types';
*/
import type { ProductCategoryManageDto } from '#/api/product';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
import { CATEGORY_CHANNEL_META, CATEGORY_CHANNEL_ORDER } from '../types';
interface Props {
@@ -14,6 +18,7 @@ interface Props {
keyword: string;
loading: boolean;
selectedCategoryId: string;
totalCount: number;
}
interface Emits {
@@ -25,9 +30,8 @@ interface Emits {
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function setKeyword(event: Event) {
const target = event.target as HTMLInputElement | null;
emit('update:keyword', target?.value ?? '');
function setKeyword(value: string | undefined) {
emit('update:keyword', value ?? '');
}
function getDisplayChannels(channels: ProductCategoryManageDto['channels']) {
@@ -40,7 +44,7 @@ function getDisplayChannels(channels: ProductCategoryManageDto['channels']) {
<div class="pcat-left-hd">
<div class="pcat-left-title">
全部分类
<span> {{ props.categories.length }} </span>
<span> {{ props.totalCount }} </span>
</div>
<div class="pcat-ch-filter">
<span
@@ -73,12 +77,16 @@ function getDisplayChannels(channels: ProductCategoryManageDto['channels']) {
</span>
</div>
<div class="pcat-search">
<input
<Input
:value="props.keyword"
type="text"
allow-clear
placeholder="搜索分类名称…"
@input="setKeyword"
/>
@update:value="setKeyword"
>
<template #prefix>
<IconifyIcon icon="lucide:search" class="pcat-search-icon" />
</template>
</Input>
</div>
</div>
@@ -87,31 +95,34 @@ function getDisplayChannels(channels: ProductCategoryManageDto['channels']) {
<div v-else-if="props.categories.length === 0" class="pcat-left-empty">
暂无分类
</div>
<div
v-for="item in props.categories"
v-else
:key="item.id"
class="pcat-cat-item"
:class="{
active: item.id === props.selectedCategoryId,
disabled: item.status === 'disabled',
}"
@click="emit('select', item.id)"
>
<span class="pcat-drag-handle"></span>
<span class="pcat-cat-name">{{ item.name }}</span>
<span class="pcat-cat-tags">
<span
v-for="channel in getDisplayChannels(item.channels)"
:key="`${item.id}-${channel}`"
class="pcat-ch-mini"
:class="CATEGORY_CHANNEL_META[channel].filterClass"
>
{{ CATEGORY_CHANNEL_META[channel].shortLabel }}
<template v-else>
<div
v-for="item in props.categories"
:key="item.id"
class="pcat-cat-item"
:class="{
active: item.id === props.selectedCategoryId,
disabled: item.status === 'disabled',
}"
@click="emit('select', item.id)"
>
<span class="pcat-drag-handle">
<IconifyIcon icon="lucide:grip-vertical" />
</span>
</span>
<span class="pcat-cat-badge">{{ item.productCount }}</span>
</div>
<span class="pcat-cat-name">{{ item.name }}</span>
<span class="pcat-cat-tags">
<span
v-for="channel in getDisplayChannels(item.channels)"
:key="`${item.id}-${channel}`"
class="pcat-ch-mini"
:class="CATEGORY_CHANNEL_META[channel].filterClass"
>
{{ CATEGORY_CHANNEL_META[channel].shortLabel }}
</span>
</span>
<span class="pcat-cat-badge">{{ item.productCount }}</span>
</div>
</template>
</div>
</div>
</template>

View File

@@ -13,6 +13,7 @@ import type {
* 2. 统一封装分类 CRUD 与商品绑定/解绑流程。
*/
import type {
ProductCategoryChannel,
ProductCategoryManageDto,
ProductPickerItemDto,
} from '#/api/product';
@@ -34,6 +35,31 @@ import { getStoreListApi } from '#/api/store';
import { CATEGORY_CHANNEL_ORDER, deriveMonthlySales } from '../types';
function normalizeCategoryChannels(
channels: ProductCategoryManageDto['channels'],
) {
const source = Array.isArray(channels)
? (channels as Array<'ts' | 'zt' | ProductCategoryChannel>)
: [];
const normalized = source
.map((channel) => {
if (channel === 'zt') return 'pickup';
if (channel === 'ts') return 'dine_in';
return channel;
})
.filter((channel): channel is ProductCategoryChannel =>
CATEGORY_CHANNEL_ORDER.includes(channel as ProductCategoryChannel),
);
const unique = [...new Set(normalized)];
if (unique.length > 0) {
return unique;
}
return [];
}
/** 分类管理页面组合式状态。 */
export function useProductCategoryPage() {
const stores = ref<StoreListItemDto[]>([]);
@@ -199,7 +225,12 @@ export function useProductCategoryPage() {
const result = await getProductCategoryManageListApi({
storeId: selectedStoreId.value,
});
categories.value = [...result].toSorted((a, b) => a.sort - b.sort);
categories.value = [...result]
.map((item) => ({
...item,
channels: normalizeCategoryChannels(item.channels),
}))
.toSorted((a, b) => a.sort - b.sort);
const nextCategoryId =
(preferredCategoryId &&
@@ -360,7 +391,7 @@ export function useProductCategoryPage() {
form.name = current.name;
form.description = current.description;
form.icon = current.icon;
form.channels = [...current.channels];
form.channels = normalizeCategoryChannels(current.channels);
form.sort = current.sort;
form.status = current.status;
isDrawerOpen.value = true;

View File

@@ -5,8 +5,9 @@
* 2. 复用门店维度工具栏与复制弹窗组件。
*/
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Card, Empty } from 'ant-design-vue';
import { Button, Card, Empty } from 'ant-design-vue';
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
@@ -92,13 +93,16 @@ const {
@copy="openCopyModal"
>
<template #actions>
<button
type="button"
class="pcat-btn pcat-btn-primary pcat-btn-lg"
<Button
type="primary"
class="pcat-add-category-btn"
@click="openCreateDrawer"
>
<template #icon>
<IconifyIcon icon="lucide:plus" />
</template>
添加分类
</button>
</Button>
</template>
</StoreScopeToolbar>
@@ -116,6 +120,7 @@ const {
:selected-category-id="selectedCategoryId"
:keyword="categoryKeyword"
:channel-filter="channelFilter"
:total-count="stats.totalCategories"
@select="selectCategory"
@update:keyword="setCategoryKeyword"
@update:channel-filter="setChannelFilter"

View File

@@ -12,6 +12,10 @@
margin-bottom: 16px;
}
.pcat-toolbar-card .ant-card-body {
padding: 12px 16px;
}
.pcat-btn {
display: inline-flex;
gap: 6px;
@@ -40,36 +44,16 @@
opacity: 0.7;
}
.pcat-btn-primary {
color: #fff;
background: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-btn-primary:hover {
color: #fff;
filter: brightness(1.04);
}
.pcat-btn-danger {
color: #ff4d4f;
background: #fff2f0;
border-color: #ffccc7;
}
.pcat-btn-danger:hover {
color: #ff4d4f;
background: #fff1f0;
border-color: #ff4d4f;
}
.pcat-btn-sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.pcat-btn-lg {
.pcat-add-category-btn.ant-btn {
display: inline-flex;
gap: 6px;
align-items: center;
height: 36px;
padding: 0 18px;
font-size: 15px;
@@ -77,6 +61,11 @@
border-radius: 10px;
}
.pcat-add-category-btn.ant-btn .iconify {
width: 14px;
height: 14px;
}
.pcat-main {
display: flex;
gap: 18px;

View File

@@ -44,6 +44,28 @@
gap: 8px;
}
.pcat-action-btn.ant-btn {
display: inline-flex;
gap: 4px;
align-items: center;
height: 32px;
padding: 0 14px;
font-size: 13px;
border-radius: 8px;
}
.pcat-action-btn.ant-btn .anticon,
.pcat-action-btn.ant-btn .iconify {
width: 13px;
height: 13px;
}
.pcat-action-btn.ant-btn-dangerous {
color: #ff4d4f;
background: #fff2f0;
border-color: #ffccc7;
}
.pcat-attr-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -74,11 +96,26 @@
}
.pcat-attr-value.icon-value {
display: flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 400;
color: #9ca3af;
}
.pcat-category-icon-preview {
width: 28px;
height: 28px;
object-fit: cover;
border: 1px solid #f3f4f6;
border-radius: 6px;
}
.pcat-category-icon-empty {
color: #9ca3af;
}
.pcat-status-tag {
display: inline-flex;
align-items: center;
@@ -143,11 +180,14 @@
justify-content: center;
width: 32px;
height: 32px;
font-size: 13px;
font-weight: 700;
border-radius: 8px;
}
.pcat-ch-icon-svg {
width: 18px;
height: 18px;
}
.pcat-ch-icon.wm {
color: #1890ff;
background: rgb(24 144 255 / 12%);
@@ -272,12 +312,23 @@
justify-content: center;
width: 36px;
height: 36px;
font-size: 11px;
overflow: hidden;
color: #bbb;
background: #f3f4f6;
border-radius: 6px;
}
.pcat-prod-thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pcat-prod-thumb-icon {
width: 16px;
height: 16px;
}
.pcat-prod-info .name {
font-weight: 500;
}
@@ -305,6 +356,17 @@
text-decoration: underline;
}
.pcat-add-product-btn.ant-btn {
display: inline-flex;
gap: 4px;
align-items: center;
height: 28px;
padding: 0 10px;
font-size: 12px;
color: #374151;
border-radius: 8px;
}
.pcat-table-empty {
padding: 30px 18px !important;
color: #9ca3af !important;

View File

@@ -1,291 +1,238 @@
.page-product-category {
.pcat-drawer-mask {
position: fixed;
inset: 0;
z-index: 1000;
pointer-events: none;
background: rgb(15 23 42 / 28%);
opacity: 0;
transition: opacity 0.2s ease;
}
.pcat-drawer-mask.open {
pointer-events: auto;
opacity: 1;
}
.pcat-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 1001;
display: flex;
flex-direction: column;
width: min(480px, 100vw);
height: 100vh;
background: #fff;
box-shadow: -8px 0 24px rgb(15 23 42 / 16%);
transform: translateX(100%);
transition: transform 0.24s ease;
}
.pcat-drawer.open {
transform: translateX(0);
}
.pcat-drawer-hd {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
.pcat-editor-drawer-wrap {
.ant-drawer-header {
padding: 0 20px;
border-bottom: 1px solid #f0f0f0;
}
.pcat-drawer-title {
.ant-drawer-header-title {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
height: 56px;
}
.ant-drawer-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-drawer-close {
width: 28px;
height: 28px;
font-size: 18px;
line-height: 1;
color: #9ca3af;
cursor: pointer;
background: transparent;
border: none;
border-radius: 6px;
}
.pcat-drawer-close:hover {
color: #4b5563;
background: #f5f5f5;
}
.pcat-drawer-bd {
flex: 1;
.ant-drawer-body {
padding: 16px 20px;
overflow-y: auto;
}
.pcat-drawer-ft {
display: flex;
gap: 10px;
justify-content: flex-end;
.ant-drawer-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
}
.pcat-form-group {
.pcat-drawer-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: #9ca3af;
cursor: pointer;
background: transparent;
border: none;
border-radius: 6px;
}
.pcat-drawer-close:hover {
color: #4b5563;
background: #f5f5f5;
}
.pcat-drawer-close .iconify {
width: 18px;
height: 18px;
}
.pcat-drawer-ft {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.pcat-drawer-form {
.ant-form-item {
margin-bottom: 16px;
}
.pcat-form-label {
display: block;
margin-bottom: 8px;
.ant-form-item-label > label {
height: auto;
font-size: 13px;
font-weight: 500;
color: #1f2937;
}
.pcat-form-label.required::before {
margin-right: 4px;
color: #ff4d4f;
content: '*';
.ant-input,
.ant-input-number-input,
.ant-input-number,
.ant-input-affix-wrapper,
.ant-input-outlined {
border-radius: 8px;
}
.pcat-input,
.pcat-textarea {
width: 100%;
padding: 8px 10px;
.ant-input,
.ant-input-number-input {
font-size: 13px;
color: #1a1a2e;
outline: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s ease;
}
.pcat-input:focus,
.pcat-textarea:focus {
border-color: var(--pcat-primary);
box-shadow: 0 0 0 3px rgb(22 119 255 / 10%);
}
.pcat-input-small {
.ant-input-number {
width: 180px;
}
.pcat-textarea {
min-height: 72px;
resize: vertical;
}
.pcat-hint {
margin-top: 6px;
font-size: 12px;
color: #9ca3af;
}
.pcat-hint.mb-8 {
margin-top: 0;
margin-bottom: 8px;
}
.pcat-icon-upload-wrap {
display: block;
}
.pcat-icon-upload-wrap .ant-upload {
display: block;
width: 80px;
}
.pcat-icon-upload {
display: flex;
flex-direction: column;
gap: 3px;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
color: #bbb;
cursor: pointer;
border: 1px dashed #e5e7eb;
border-radius: 10px;
}
.pcat-icon-upload:hover {
color: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-icon-preview {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.pcat-icon-upload-sign {
font-size: 20px;
line-height: 1;
}
.pcat-icon-upload-text {
font-size: 11px;
}
.pcat-drawer-channels {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pcat-drawer-ch {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 8px 14px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
user-select: none;
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s ease;
}
.pcat-drawer-ch-icon {
width: 14px;
height: 14px;
}
.pcat-drawer-ch:hover {
border-color: var(--pcat-primary);
}
.pcat-drawer-ch.checked {
font-weight: 500;
color: var(--pcat-primary);
background: rgb(22 119 255 / 6%);
border-color: var(--pcat-primary);
}
.pcat-ch-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: transparent;
border: 1.5px solid #d1d5db;
border-radius: 4px;
transition: all 0.2s ease;
}
.pcat-drawer-ch.checked .pcat-ch-check {
color: #fff;
background: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-toggle-row {
display: flex;
gap: 10px;
align-items: center;
}
.pcat-toggle {
position: relative;
width: 42px;
height: 24px;
cursor: pointer;
background: #d9d9d9;
border: none;
border-radius: 20px;
transition: all 0.2s ease;
}
.pcat-toggle::after {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
content: '';
background: #fff;
border-radius: 50%;
transition: all 0.2s ease;
}
.pcat-toggle.on {
background: var(--pcat-primary);
}
.pcat-toggle.on::after {
left: 20px;
}
.pcat-toggle-label {
font-size: 13px;
color: #4b5563;
}
.pcat-cropper-wrap {
height: 360px;
overflow: hidden;
border-radius: 10px;
}
.pcat-crop-hint {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
.ant-input-number .ant-input-number-input {
height: 30px;
}
}
.pcat-sort-input {
width: 180px;
}
.pcat-hint {
margin-top: 6px;
font-size: 12px;
color: #9ca3af;
}
.pcat-hint.mb-8 {
margin-top: 0;
margin-bottom: 8px;
}
.pcat-icon-upload {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
color: #bbb;
cursor: pointer;
background: #fff;
border: 1px dashed #e5e7eb;
border-radius: 10px;
transition: all 0.2s ease;
}
.pcat-icon-upload:hover {
color: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-icon-plus {
font-size: 20px;
line-height: 1;
}
.pcat-icon-preview {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.pcat-icon-upload-text {
font-size: 11px;
}
.pcat-editor-drawer-wrap .ant-upload-wrapper,
.pcat-editor-drawer-wrap .ant-upload-select,
.pcat-editor-drawer-wrap .ant-upload {
display: block;
width: 80px;
height: 80px;
}
.pcat-drawer-channels {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pcat-drawer-ch {
display: inline-flex;
gap: 6px;
align-items: center;
height: auto;
padding: 8px 14px;
font-size: 13px;
color: #4b5563;
white-space: nowrap;
cursor: pointer;
user-select: none;
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
box-shadow: none;
transition: all 0.2s ease;
}
.pcat-drawer-ch:hover {
color: var(--pcat-primary);
background: #fff;
border-color: var(--pcat-primary);
}
.pcat-drawer-ch:focus,
.pcat-drawer-ch:active {
color: var(--pcat-primary);
background: #fff;
border-color: var(--pcat-primary);
box-shadow: 0 0 0 2px rgb(22 119 255 / 10%);
}
.pcat-drawer-ch-icon {
width: 14px;
height: 14px;
}
.pcat-ch-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: #fff;
border: 1.5px solid #d1d5db;
border-radius: 4px;
transition: all 0.2s ease;
}
.pcat-ch-check-icon {
width: 10px;
height: 10px;
color: transparent;
}
.pcat-toggle-row {
display: flex;
gap: 10px;
align-items: center;
}
.pcat-toggle-label {
font-size: 13px;
color: #4b5563;
}
.pcat-cropper-wrap {
height: 360px;
overflow: hidden;
border-radius: 10px;
}
.pcat-crop-hint {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}

View File

@@ -64,26 +64,53 @@
.pcat-search input {
width: 100%;
height: 32px;
padding: 0 10px;
font-size: 12px;
color: #1a1a2e;
outline: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.pcat-search input:focus {
.pcat-search .ant-input-affix-wrapper {
height: 32px;
padding: 0 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: none;
}
.pcat-search .ant-input-affix-wrapper-focused,
.pcat-search .ant-input-affix-wrapper:focus-within {
border-color: var(--pcat-primary);
box-shadow: 0 0 0 3px rgb(22 119 255 / 10%);
}
.pcat-search .ant-input {
padding: 0;
font-size: 12px;
color: #1a1a2e;
}
.pcat-search .ant-input::placeholder {
color: #9ca3af;
}
.pcat-search-icon {
width: 14px;
height: 14px;
color: #bbb;
}
.pcat-left-list {
flex: 1;
padding: 6px 0;
overflow-y: auto;
}
.pcat-left-list::-webkit-scrollbar {
width: 3px;
}
.pcat-left-list::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 2px;
}
.pcat-left-empty {
padding: 30px 16px;
font-size: 13px;
@@ -118,9 +145,15 @@
}
.pcat-drag-handle {
display: flex;
flex-shrink: 0;
align-items: center;
color: #bbb;
letter-spacing: -1px;
}
.pcat-drag-handle svg {
width: 14px;
height: 14px;
}
.pcat-cat-name {

View File

@@ -35,12 +35,14 @@ export interface CategoryStatsViewModel {
export interface CategoryChannelMeta {
cardClass: 'ts' | 'wm' | 'zt';
filterClass: 'ts' | 'wm' | 'zt';
icon: string;
label: string;
shortLabel: string;
subText: string;
}
export interface ProductPreviewItem extends ProductPickerItemDto {
imageUrl?: string;
monthlySales: number;
}
@@ -60,6 +62,7 @@ export const CATEGORY_CHANNEL_META: Record<
CategoryChannelMeta
> = {
wm: {
icon: 'lucide:bike',
label: '外卖',
shortLabel: '外',
subText: '外卖平台展示此分类',
@@ -67,6 +70,7 @@ export const CATEGORY_CHANNEL_META: Record<
cardClass: 'wm',
},
pickup: {
icon: 'lucide:shopping-bag',
label: '自提',
shortLabel: '自',
subText: '到店自提展示此分类',
@@ -74,6 +78,7 @@ export const CATEGORY_CHANNEL_META: Record<
cardClass: 'zt',
},
dine_in: {
icon: 'lucide:utensils',
label: '堂食',
shortLabel: '堂',
subText: '堂食扫码点餐展示此分类',