diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index aa84c9e..6fd6b72 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -386,11 +386,9 @@ export interface BindProductAddonGroupProductsDto { /** 商品标签。 */ export interface ProductLabelDto { color: string; - description: string; id: string; name: string; productCount: number; - productIds: string[]; sort: number; status: ProductSwitchStatus; updatedAt: string; @@ -406,10 +404,8 @@ export interface ProductLabelQuery { /** 保存标签参数。 */ export interface SaveProductLabelDto { color: string; - description: string; id?: string; name: string; - productIds: string[]; sort: number; status: ProductSwitchStatus; storeId: string; diff --git a/apps/web-antd/src/mock/product-extensions.ts b/apps/web-antd/src/mock/product-extensions.ts index ba89b01..76d9cbf 100644 --- a/apps/web-antd/src/mock/product-extensions.ts +++ b/apps/web-antd/src/mock/product-extensions.ts @@ -79,10 +79,9 @@ interface AddonGroupRecord { interface LabelRecord { color: string; - description: string; id: string; name: string; - productIds: string[]; + productCount: number; sort: number; status: ProductSwitchStatus; updatedAt: string; @@ -289,9 +288,6 @@ function cleanupRelationIds(state: ProductExtensionStoreState) { for (const group of state.addonGroups) { group.productIds = group.productIds.filter((item) => idSet.has(item)); } - for (const label of state.labels) { - label.productIds = label.productIds.filter((item) => idSet.has(item)); - } for (const schedule of state.schedules) { schedule.productIds = schedule.productIds.filter((item) => idSet.has(item)); } @@ -386,18 +382,14 @@ function toAddonGroupItem( }; } -function toLabelItem(state: ProductExtensionStoreState, item: LabelRecord) { - const idSet = new Set(state.products.map((product) => product.id)); - const productIds = item.productIds.filter((id) => idSet.has(id)); +function toLabelItem(item: LabelRecord) { return { id: item.id, name: item.name, color: item.color, - description: item.description, sort: item.sort, status: item.status, - productIds, - productCount: productIds.length, + productCount: item.productCount, updatedAt: item.updatedAt, }; } @@ -540,20 +532,18 @@ function createDefaultState(storeId: string): ProductExtensionStoreState { id: createId('label', storeId), name: '招牌', color: '#cf1322', - description: '店铺主推商品', sort: 1, status: 'enabled', - productIds: products.slice(0, 6).map((item) => item.id), + productCount: 6, updatedAt: toDateTimeText(new Date()), }, { id: createId('label', storeId), name: '新品', color: '#2f54eb', - description: '近 30 天上新', sort: 2, status: 'enabled', - productIds: products.slice(6, 12).map((item) => item.id), + productCount: 6, updatedAt: toDateTimeText(new Date()), }, ]; @@ -1220,12 +1210,9 @@ Mock.mock( .filter((item) => { if (status && item.status !== status) return false; if (!keyword) return true; - return ( - item.name.toLowerCase().includes(keyword) || - item.description.toLowerCase().includes(keyword) - ); + return item.name.toLowerCase().includes(keyword); }) - .map((item) => toLabelItem(state, item)); + .map((item) => toLabelItem(item)); return { code: 200, data: list }; }, @@ -1244,9 +1231,6 @@ Mock.mock(/\/product\/label\/save/, 'post', (options: MockRequestOptions) => { const existingIndex = state.labels.findIndex((item) => item.id === id); const fallbackSort = state.labels.reduce((max, item) => Math.max(max, item.sort), 0) + 1; - const productIds = normalizeIdList(body.productIds).filter((productId) => - state.products.some((item) => item.id === productId), - ); const next: LabelRecord = existingIndex === -1 @@ -1254,26 +1238,20 @@ Mock.mock(/\/product\/label\/save/, 'post', (options: MockRequestOptions) => { id: createId('label', storeId), name, color: normalizeText(body.color, '#1677ff'), - description: normalizeText(body.description), sort: normalizeInt(body.sort, fallbackSort, 1), status: normalizeSwitchStatus(body.status, 'enabled'), - productIds, + productCount: 0, updatedAt: toDateTimeText(new Date()), } : { ...state.labels[existingIndex], name, color: normalizeText(body.color, state.labels[existingIndex].color), - description: normalizeText( - body.description, - state.labels[existingIndex].description, - ), sort: normalizeInt(body.sort, state.labels[existingIndex].sort, 1), status: normalizeSwitchStatus( body.status, state.labels[existingIndex].status, ), - productIds, updatedAt: toDateTimeText(new Date()), }; @@ -1283,7 +1261,7 @@ Mock.mock(/\/product\/label\/save/, 'post', (options: MockRequestOptions) => { state.labels.splice(existingIndex, 1, next); } - return { code: 200, data: toLabelItem(state, next) }; + return { code: 200, data: toLabelItem(next) }; }); Mock.mock(/\/product\/label\/delete/, 'post', (options: MockRequestOptions) => { @@ -1308,7 +1286,7 @@ Mock.mock(/\/product\/label\/status/, 'post', (options: MockRequestOptions) => { if (!target) return { code: 404, data: null, message: '标签不存在' }; target.status = normalizeSwitchStatus(body.status, target.status); target.updatedAt = toDateTimeText(new Date()); - return { code: 200, data: toLabelItem(state, target) }; + return { code: 200, data: toLabelItem(target) }; }); Mock.mock( diff --git a/apps/web-antd/src/views/product/labels/components/LabelEditorDrawer.vue b/apps/web-antd/src/views/product/labels/components/LabelEditorDrawer.vue new file mode 100644 index 0000000..19cd4bf --- /dev/null +++ b/apps/web-antd/src/views/product/labels/components/LabelEditorDrawer.vue @@ -0,0 +1,173 @@ + + + + + + + + + {{ title }} + + + + + + + + 标签名称 + + emit('setName', (event.target as HTMLInputElement).value) + " + /> + + + + 标签颜色 + + + + + + + + + + + 预览 + + + {{ previewText }} + + + + + + 排序 + + emit( + 'setSort', + Number((event.target as HTMLInputElement).value || 0), + ) + " + /> + + + + 启用状态 + + + + {{ form.status === 'enabled' ? '启用' : '停用' }} + + + + + + + 取消 + + {{ submitText }} + + + + + diff --git a/apps/web-antd/src/views/product/labels/components/LabelListTable.vue b/apps/web-antd/src/views/product/labels/components/LabelListTable.vue new file mode 100644 index 0000000..4c56787 --- /dev/null +++ b/apps/web-antd/src/views/product/labels/components/LabelListTable.vue @@ -0,0 +1,101 @@ + + + + + + + 标签预览 + 标签名称 + 颜色 + 已关联商品 + 状态 + 操作 + + + + + + + {{ item.name }} + + + {{ item.name }} + + + {{ getColor(item.color) }} + + {{ item.productCount }}个商品 + + + + {{ item.status === 'enabled' ? '启用' : '停用' }} + + + + + 编辑 + + + 启用 + + + 停用 + + + 删除 + + + + + + diff --git a/apps/web-antd/src/views/product/labels/composables/labels-page/constants.ts b/apps/web-antd/src/views/product/labels/composables/labels-page/constants.ts new file mode 100644 index 0000000..1145c48 --- /dev/null +++ b/apps/web-antd/src/views/product/labels/composables/labels-page/constants.ts @@ -0,0 +1,42 @@ +import type { LabelColorOption, LabelEditorForm } from '../../types'; + +/** + * 文件职责:标签页面常量与基础构造方法。 + */ +export const LABEL_COLOR_OPTIONS: LabelColorOption[] = [ + { value: '#1890ff' }, + { value: '#ff4d4f' }, + { value: '#fa8c16' }, + { value: '#52c41a' }, + { value: '#722ed1' }, + { value: '#eb2f96' }, + { value: '#13c2c2' }, + { value: '#999999' }, +]; + +export const DEFAULT_LABEL_COLOR = LABEL_COLOR_OPTIONS[0]?.value ?? '#1890ff'; + +export function normalizeLabelColor(value: null | string | undefined) { + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + if (!normalized) return DEFAULT_LABEL_COLOR; + if (normalized === '#999') return '#999999'; + if (/^#[\da-f]{6}$/.test(normalized)) return normalized; + if (/^#[\da-f]{3}$/.test(normalized)) { + const r = normalized[1]; + const g = normalized[2]; + const b = normalized[3]; + return `#${r}${r}${g}${g}${b}${b}`; + } + return DEFAULT_LABEL_COLOR; +} + +export function createDefaultLabelForm(sort = 0): LabelEditorForm { + return { + name: '', + color: DEFAULT_LABEL_COLOR, + sort, + status: 'enabled', + }; +} diff --git a/apps/web-antd/src/views/product/labels/composables/labels-page/data-actions.ts b/apps/web-antd/src/views/product/labels/composables/labels-page/data-actions.ts new file mode 100644 index 0000000..edb7e1d --- /dev/null +++ b/apps/web-antd/src/views/product/labels/composables/labels-page/data-actions.ts @@ -0,0 +1,78 @@ +import type { Ref } from 'vue'; + +import type { ProductLabelDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; + +/** + * 文件职责:标签页面数据动作。 + * 1. 加载门店列表与标签列表。 + * 2. 维护门店切换时的数据一致性。 + */ +import { message } from 'ant-design-vue'; + +import { getProductLabelListApi } from '#/api/product'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + isLoading: Ref; + isStoreLoading: Ref; + rows: Ref; + selectedStoreId: Ref; + stores: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + page: 1, + pageSize: 200, + }); + options.stores.value = result.items ?? []; + if (options.stores.value.length === 0) { + options.selectedStoreId.value = ''; + options.rows.value = []; + return; + } + + const hasSelected = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!hasSelected) { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadLabels() { + if (!options.selectedStoreId.value) { + options.rows.value = []; + return; + } + + options.isLoading.value = true; + try { + const list = await getProductLabelListApi({ + storeId: options.selectedStoreId.value, + }); + options.rows.value = list.toSorted((a, b) => a.sort - b.sort); + } catch (error) { + console.error(error); + options.rows.value = []; + message.error('加载标签失败'); + } finally { + options.isLoading.value = false; + } + } + + return { + loadLabels, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/product/labels/composables/labels-page/drawer-actions.ts b/apps/web-antd/src/views/product/labels/composables/labels-page/drawer-actions.ts new file mode 100644 index 0000000..55eaaec --- /dev/null +++ b/apps/web-antd/src/views/product/labels/composables/labels-page/drawer-actions.ts @@ -0,0 +1,123 @@ +import type { Ref } from 'vue'; + +import type { SaveProductLabelDto } from '#/api/product'; +import type { + LabelEditorForm, + ProductLabelTableRowViewModel, +} from '#/views/product/labels/types'; + +/** + * 文件职责:标签编辑抽屉动作。 + * 1. 管理标签新增/编辑抽屉与表单状态。 + * 2. 处理表单校验与保存提交。 + */ +import { message } from 'ant-design-vue'; + +import { saveProductLabelApi } from '#/api/product'; + +import { createDefaultLabelForm, normalizeLabelColor } from './constants'; + +interface CreateDrawerActionsOptions { + drawerMode: Ref<'create' | 'edit'>; + editingLabelId: Ref; + form: LabelEditorForm; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadLabels: () => Promise; + rows: Ref; + selectedStoreId: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormColor(value: string) { + options.form.color = normalizeLabelColor(value); + } + + function setFormSort(value: number) { + options.form.sort = Number.isNaN(value) + ? 0 + : Math.max(0, Math.floor(value)); + } + + function toggleFormStatus() { + options.form.status = + options.form.status === 'enabled' ? 'disabled' : 'enabled'; + } + + function resetForm() { + const next = createDefaultLabelForm(options.rows.value.length + 1); + options.editingLabelId.value = ''; + options.form.name = next.name; + options.form.color = next.color; + options.form.sort = next.sort; + options.form.status = next.status; + } + + function openCreateDrawer() { + options.drawerMode.value = 'create'; + resetForm(); + options.isDrawerOpen.value = true; + } + + function openEditDrawer(item: ProductLabelTableRowViewModel) { + options.drawerMode.value = 'edit'; + options.editingLabelId.value = item.id; + options.form.name = item.name; + options.form.color = normalizeLabelColor(item.color); + options.form.sort = item.sort; + options.form.status = item.status; + options.isDrawerOpen.value = true; + } + + function buildSavePayload(): SaveProductLabelDto { + return { + storeId: options.selectedStoreId.value, + id: options.editingLabelId.value || undefined, + name: options.form.name.trim(), + color: normalizeLabelColor(options.form.color), + sort: options.form.sort, + status: options.form.status, + }; + } + + async function submitDrawer() { + if (!options.selectedStoreId.value) return; + if (!options.form.name.trim()) { + message.warning('请输入标签名称'); + return; + } + + options.isDrawerSubmitting.value = true; + try { + await saveProductLabelApi(buildSavePayload()); + message.success( + options.drawerMode.value === 'create' ? '标签已创建' : '标签已更新', + ); + options.isDrawerOpen.value = false; + await options.loadLabels(); + } catch (error) { + console.error(error); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + return { + openCreateDrawer, + openEditDrawer, + setDrawerOpen, + setFormColor, + setFormName, + setFormSort, + submitDrawer, + toggleFormStatus, + }; +} diff --git a/apps/web-antd/src/views/product/labels/composables/labels-page/label-actions.ts b/apps/web-antd/src/views/product/labels/composables/labels-page/label-actions.ts new file mode 100644 index 0000000..3775c90 --- /dev/null +++ b/apps/web-antd/src/views/product/labels/composables/labels-page/label-actions.ts @@ -0,0 +1,73 @@ +import type { Ref } from 'vue'; + +import type { ProductLabelTableRowViewModel } from '#/views/product/labels/types'; + +/** + * 文件职责:标签行操作动作。 + * 1. 处理标签删除。 + * 2. 处理标签启用与停用。 + */ +import { message, Modal } from 'ant-design-vue'; + +import { + changeProductLabelStatusApi, + deleteProductLabelApi, +} from '#/api/product'; + +interface CreateLabelActionsOptions { + loadLabels: () => Promise; + selectedStoreId: Ref; +} + +export function createLabelActions(options: CreateLabelActionsOptions) { + async function updateStatus( + item: ProductLabelTableRowViewModel, + status: 'disabled' | 'enabled', + successMessage: string, + ) { + if (!options.selectedStoreId.value) return; + try { + await changeProductLabelStatusApi({ + storeId: options.selectedStoreId.value, + labelId: item.id, + status, + }); + message.success(successMessage); + await options.loadLabels(); + } catch (error) { + console.error(error); + message.error('标签状态更新失败'); + } + } + + async function enableLabel(item: ProductLabelTableRowViewModel) { + await updateStatus(item, 'enabled', '标签已启用'); + } + + async function disableLabel(item: ProductLabelTableRowViewModel) { + await updateStatus(item, 'disabled', '标签已停用'); + } + + function removeLabel(item: ProductLabelTableRowViewModel) { + if (!options.selectedStoreId.value) return; + Modal.confirm({ + title: `确认删除标签「${item.name}」吗?`, + okText: '确认删除', + cancelText: '取消', + async onOk() { + await deleteProductLabelApi({ + storeId: options.selectedStoreId.value, + labelId: item.id, + }); + message.success('标签已删除'); + await options.loadLabels(); + }, + }); + } + + return { + disableLabel, + enableLabel, + removeLabel, + }; +} diff --git a/apps/web-antd/src/views/product/labels/composables/useProductLabelsPage.ts b/apps/web-antd/src/views/product/labels/composables/useProductLabelsPage.ts new file mode 100644 index 0000000..51c89a3 --- /dev/null +++ b/apps/web-antd/src/views/product/labels/composables/useProductLabelsPage.ts @@ -0,0 +1,141 @@ +import type { LabelEditorForm, ProductLabelTableRowViewModel } from '../types'; + +import type { StoreListItemDto } from '#/api/store'; + +/** + * 文件职责:标签页面状态与行为编排。 + * 1. 管理门店、列表、筛选与统计状态。 + * 2. 封装标签新增编辑、启停与删除流程。 + */ +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { createDefaultLabelForm } from './labels-page/constants'; +import { createDataActions } from './labels-page/data-actions'; +import { createDrawerActions } from './labels-page/drawer-actions'; +import { createLabelActions } from './labels-page/label-actions'; + +export function useProductLabelsPage() { + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const rows = ref([]); + const isLoading = ref(false); + const keyword = ref(''); + + const isDrawerOpen = ref(false); + const isDrawerSubmitting = ref(false); + const drawerMode = ref<'create' | 'edit'>('create'); + const editingLabelId = ref(''); + + const form = reactive(createDefaultLabelForm(1)); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const filteredRows = computed(() => { + const normalized = keyword.value.trim().toLowerCase(); + if (!normalized) return rows.value; + return rows.value.filter((item) => + item.name.toLowerCase().includes(normalized), + ); + }); + + const labelCount = computed(() => rows.value.length); + + const enabledCount = computed( + () => rows.value.filter((item) => item.status === 'enabled').length, + ); + + const relatedProductCount = computed(() => + rows.value.reduce((sum, item) => sum + item.productCount, 0), + ); + + const drawerTitle = computed(() => + drawerMode.value === 'create' ? '添加标签' : '编辑标签', + ); + + const drawerSubmitText = computed(() => '保存'); + + const { loadLabels, loadStores } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + rows, + isLoading, + }); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setKeyword(value: string) { + keyword.value = value; + } + + const { + openCreateDrawer, + openEditDrawer, + setDrawerOpen, + setFormColor, + setFormName, + setFormSort, + submitDrawer, + toggleFormStatus, + } = createDrawerActions({ + drawerMode, + editingLabelId, + form, + isDrawerOpen, + isDrawerSubmitting, + loadLabels, + rows, + selectedStoreId, + }); + + const { disableLabel, enableLabel, removeLabel } = createLabelActions({ + selectedStoreId, + loadLabels, + }); + + watch(selectedStoreId, () => { + keyword.value = ''; + void loadLabels(); + }); + + onMounted(loadStores); + + return { + disableLabel, + drawerSubmitText, + drawerTitle, + enableLabel, + enabledCount, + filteredRows, + form, + isDrawerOpen, + isDrawerSubmitting, + isLoading, + isStoreLoading, + keyword, + labelCount, + openCreateDrawer, + openEditDrawer, + relatedProductCount, + removeLabel, + selectedStoreId, + setDrawerOpen, + setFormColor, + setFormName, + setFormSort, + setKeyword, + setSelectedStoreId, + storeOptions, + submitDrawer, + toggleFormStatus, + }; +} diff --git a/apps/web-antd/src/views/product/labels/index.vue b/apps/web-antd/src/views/product/labels/index.vue index aff7250..45ecafa 100644 --- a/apps/web-antd/src/views/product/labels/index.vue +++ b/apps/web-antd/src/views/product/labels/index.vue @@ -1,424 +1,120 @@ - - - + + + setSelectedStoreId(String(value ?? ''))" /> setKeyword(String(value ?? ''))" /> - - 查询 - 重置 - 新增标签 - - + + + 添加标签 + - - - + + + 标签总数 {{ labelCount }} + + + 启用 {{ enabledCount }} + + + 关联商品 {{ relatedProductCount }} + + - - - - - {{ record.name }} - - - - - - - - toggleLabelStatus(record, checked === true)" - /> - - - - - - - 编辑 - - 删除 - - - - - - + + 暂无门店,请先创建门店 + - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - 取消 - - 保存 - - - - + :submit-text="drawerSubmitText" + :submitting="isDrawerSubmitting" + :form="form" + :color-options="LABEL_COLOR_OPTIONS" + @close="setDrawerOpen(false)" + @set-name="setFormName" + @set-color="setFormColor" + @set-sort="setFormSort" + @toggle-status="toggleFormStatus" + @submit="submitDrawer" + /> - diff --git a/apps/web-antd/src/views/product/labels/styles/base.less b/apps/web-antd/src/views/product/labels/styles/base.less new file mode 100644 index 0000000..d84e7cf --- /dev/null +++ b/apps/web-antd/src/views/product/labels/styles/base.less @@ -0,0 +1,190 @@ +/** + * 文件职责:标签页面基础样式。 + * 1. 定义通用变量、操作按钮与抽屉骨架。 + * 2. 定义按钮与开关的基础视觉。 + */ +:root { + --g-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --g-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --g-shadow-md: 0 4px 12px rgb(0 0 0 / 7%), 0 1px 3px rgb(0 0 0 / 4%); +} + +.g-action { + padding: 0; + font-size: 13px; + color: #1677ff; + cursor: pointer; + background: none; + border: none; +} + +.g-action + .g-action { + margin-left: 12px; +} + +.g-action-danger { + color: #ef4444; +} + +.g-drawer-mask { + position: fixed; + inset: 0; + z-index: 1000; + pointer-events: none; + background: rgb(0 0 0 / 45%); + opacity: 0; + transition: opacity 0.3s; +} + +.g-drawer-mask.open { + pointer-events: auto; + opacity: 1; +} + +.g-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 1001; + display: flex; + flex-direction: column; + background: #fff; + box-shadow: -6px 0 16px rgb(0 0 0 / 8%); + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1); +} + +.g-drawer.open { + transform: translateX(0); +} + +.g-drawer-hd { + display: flex; + flex-shrink: 0; + align-items: center; + height: 54px; + padding: 0 20px; + border-bottom: 1px solid #f0f0f0; +} + +.g-drawer-title { + flex: 1; + font-size: 16px; + font-weight: 600; + color: #1a1a2e; +} + +.g-drawer-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + color: #999; + cursor: pointer; + background: none; + border: none; + border-radius: 6px; +} + +.g-drawer-close .iconify { + width: 16px; + height: 16px; +} + +.g-drawer-close:hover { + color: #333; + background: #f5f5f5; +} + +.g-drawer-bd { + flex: 1; + padding: 20px 24px; + overflow-y: auto; +} + +.g-drawer-ft { + display: flex; + flex-shrink: 0; + gap: 8px; + justify-content: flex-end; + padding: 12px 20px; + border-top: 1px solid #f0f0f0; +} + +.g-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 16px; + font-size: 13px; + color: #1f1f1f; + cursor: pointer; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 6px; + box-shadow: var(--g-shadow-sm); + transition: all var(--g-transition); +} + +.g-btn:hover { + color: #1677ff; + border-color: #1677ff; + box-shadow: var(--g-shadow-md); +} + +.g-btn-primary { + color: #fff; + background: #1677ff; + border-color: #1677ff; +} + +.g-btn-primary:hover { + color: #fff; + opacity: 0.88; +} + +.g-btn:disabled { + cursor: not-allowed; + box-shadow: none; + opacity: 0.6; +} + +.g-toggle { + position: relative; + width: 40px; + height: 22px; + cursor: pointer; + background: #d9d9d9; + border: none; + border-radius: 11px; + transition: all 0.2s; +} + +.g-toggle::before { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + content: ''; + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 3px rgb(0 0 0 / 15%); + transition: all 0.2s; +} + +.g-toggle.on { + background: #1677ff; +} + +.g-toggle.on::before { + transform: translateX(18px); +} + +.g-toggle-label { + font-size: 12px; + color: #4b5563; +} diff --git a/apps/web-antd/src/views/product/labels/styles/card.less b/apps/web-antd/src/views/product/labels/styles/card.less new file mode 100644 index 0000000..639cb5b --- /dev/null +++ b/apps/web-antd/src/views/product/labels/styles/card.less @@ -0,0 +1,92 @@ +.page-product-labels { + .plbl-card { + padding: 20px; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .plbl-table { + width: 100%; + font-size: 13px; + border-collapse: collapse; + } + + .plbl-table th { + padding: 10px 12px; + font-weight: 600; + color: #6b7280; + text-align: left; + white-space: nowrap; + background: #f8f9fb; + border-bottom: 1px solid #f3f4f6; + } + + .plbl-table td { + padding: 12px; + vertical-align: middle; + color: #1a1a2e; + border-bottom: 1px solid #f3f4f6; + } + + .plbl-table tr:last-child td { + border-bottom: none; + } + + .plbl-table tr:hover td { + background: rgb(22 119 255 / 3%); + } + + .plbl-table tr.disabled td { + opacity: 0.5; + } + + .plbl-pill { + display: inline-block; + padding: 2px 10px; + font-size: 12px; + font-weight: 600; + line-height: 20px; + color: #fff; + border-radius: 6px; + } + + .plbl-color-dot { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 6px; + vertical-align: middle; + border-radius: 50%; + } + + .plbl-status { + display: inline-flex; + gap: 5px; + align-items: center; + font-size: 12px; + font-weight: 600; + } + + .plbl-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + } + + .plbl-status-on { + color: #22c55e; + } + + .plbl-status-on .plbl-status-dot { + background: #22c55e; + } + + .plbl-status-off { + color: #9ca3af; + } + + .plbl-status-off .plbl-status-dot { + background: #9ca3af; + } +} diff --git a/apps/web-antd/src/views/product/labels/styles/drawer.less b/apps/web-antd/src/views/product/labels/styles/drawer.less new file mode 100644 index 0000000..f414611 --- /dev/null +++ b/apps/web-antd/src/views/product/labels/styles/drawer.less @@ -0,0 +1,106 @@ +.plbl-editor-drawer { + .g-form-group { + margin-bottom: 16px; + } + + .g-form-label { + display: inline-flex; + margin-bottom: 8px; + font-size: 13px; + font-weight: 500; + color: #1a1a2e; + } + + .g-form-label.required::before { + margin-right: 4px; + color: #ef4444; + content: '*'; + } + + .g-input { + width: 100%; + height: 34px; + padding: 0 10px; + font-size: 13px; + color: #1a1a2e; + outline: none; + border: 1px solid #e5e7eb; + border-radius: 8px; + transition: var(--g-transition); + } + + .g-input:focus { + border-color: #1677ff; + box-shadow: 0 0 0 3px rgb(22 119 255 / 12%); + } + + .plbl-color-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + } + + .plbl-color-swatch { + position: relative; + width: 100%; + aspect-ratio: 1; + cursor: pointer; + border: 2px solid transparent; + border-radius: 10px; + transition: var(--g-transition); + } + + .plbl-color-swatch:hover { + box-shadow: 0 0 0 3px rgb(0 0 0 / 10%); + } + + .plbl-color-swatch.selected { + border-color: #1a1a2e; + box-shadow: 0 0 0 2px rgb(0 0 0 / 10%); + } + + .plbl-check { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + color: #fff; + } + + .plbl-check .iconify { + width: 18px; + height: 18px; + } + + .plbl-color-swatch.selected .plbl-check { + display: inline-flex; + } + + .plbl-preview-area { + display: flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 16px; + background: #f8f9fb; + border: 1px solid #e5e7eb; + border-radius: 10px; + } + + .plbl-pill { + display: inline-block; + padding: 2px 10px; + font-size: 12px; + font-weight: 600; + line-height: 20px; + color: #fff; + border-radius: 6px; + } + + .plbl-toggle-row { + display: flex; + gap: 10px; + align-items: center; + } +} diff --git a/apps/web-antd/src/views/product/labels/styles/index.less b/apps/web-antd/src/views/product/labels/styles/index.less new file mode 100644 index 0000000..1156c2c --- /dev/null +++ b/apps/web-antd/src/views/product/labels/styles/index.less @@ -0,0 +1,5 @@ +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/product/labels/styles/layout.less b/apps/web-antd/src/views/product/labels/styles/layout.less new file mode 100644 index 0000000..897fa75 --- /dev/null +++ b/apps/web-antd/src/views/product/labels/styles/layout.less @@ -0,0 +1,81 @@ +.page-product-labels { + .plbl-page { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 960px; + } + + .plbl-toolbar { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 16px; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .plbl-store-select { + width: 220px; + } + + .plbl-store-select .ant-select-selector { + height: 34px !important; + border-color: #e5e7eb !important; + border-radius: 8px !important; + } + + .plbl-store-select .ant-select-selection-item { + line-height: 32px !important; + } + + .plbl-search { + width: 220px; + } + + .plbl-search .ant-input { + height: 34px; + padding-left: 32px; + font-size: 13px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23bcbcbc' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E") + 10px center no-repeat; + border-radius: 8px; + } + + .plbl-spacer { + flex: 1; + } + + .plbl-stats { + display: flex; + gap: 24px; + padding: 10px 16px; + font-size: 13px; + color: #4b5563; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .plbl-stats span { + display: flex; + gap: 6px; + align-items: center; + } + + .plbl-stats strong { + font-weight: 600; + color: #1a1a2e; + } + + .plbl-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } +} diff --git a/apps/web-antd/src/views/product/labels/styles/responsive.less b/apps/web-antd/src/views/product/labels/styles/responsive.less new file mode 100644 index 0000000..d170480 --- /dev/null +++ b/apps/web-antd/src/views/product/labels/styles/responsive.less @@ -0,0 +1,11 @@ +.page-product-labels { + @media (width <= 1200px) { + .plbl-toolbar { + flex-wrap: wrap; + } + + .plbl-spacer { + display: none; + } + } +} diff --git a/apps/web-antd/src/views/product/labels/types.ts b/apps/web-antd/src/views/product/labels/types.ts new file mode 100644 index 0000000..2c2aa75 --- /dev/null +++ b/apps/web-antd/src/views/product/labels/types.ts @@ -0,0 +1,21 @@ +import type { ProductLabelDto, ProductSwitchStatus } from '#/api/product'; + +/** + * 文件职责:商品标签页面类型定义。 + */ + +/** 标签颜色项。 */ +export interface LabelColorOption { + value: string; +} + +/** 标签编辑表单。 */ +export interface LabelEditorForm { + color: string; + name: string; + sort: number; + status: ProductSwitchStatus; +} + +/** 标签列表行视图模型。 */ +export type ProductLabelTableRowViewModel = ProductLabelDto;