feat(project): restore product labels page and split modules

This commit is contained in:
2026-02-21 10:17:58 +08:00
parent 83ea84bf11
commit a53db5f0a4
17 changed files with 1341 additions and 434 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
/**
* 文件职责:标签编辑抽屉。
* 1. 承载标签名称、颜色、排序、启停表单。
* 2. 对外抛出字段变更与保存事件。
*/
import type { LabelColorOption, LabelEditorForm } from '../types';
import { computed, onBeforeUnmount, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { normalizeLabelColor } from '../composables/labels-page/constants';
const props = defineProps<{
colorOptions: LabelColorOption[];
form: LabelEditorForm;
open: boolean;
submitText: string;
submitting: boolean;
title: string;
}>();
const emit = defineEmits<{
close: [];
setColor: [value: string];
setName: [value: string];
setSort: [value: number];
submit: [];
toggleStatus: [];
}>();
const previewText = computed(() => props.form.name.trim() || '标签名称');
const previewColor = computed(() => normalizeLabelColor(props.form.color));
function closeDrawer() {
emit('close');
}
function submit() {
if (props.submitting) return;
emit('submit');
}
function setBodyLock(locked: boolean) {
if (typeof document === 'undefined') return;
document.body.style.overflow = locked ? 'hidden' : '';
}
watch(
() => props.open,
(open) => {
setBodyLock(open);
},
{ immediate: true },
);
onBeforeUnmount(() => {
setBodyLock(false);
});
</script>
<template>
<Teleport to="body">
<div class="g-drawer-mask" :class="{ open }" @click="closeDrawer"></div>
<div
class="g-drawer plbl-editor-drawer"
:class="{ open }"
style="width: 460px"
>
<div class="g-drawer-hd">
<span class="g-drawer-title">{{ title }}</span>
<button type="button" class="g-drawer-close" @click="closeDrawer">
<IconifyIcon icon="lucide:x" />
</button>
</div>
<div class="g-drawer-bd">
<div class="g-form-group">
<label class="g-form-label required">标签名称</label>
<input
type="text"
class="g-input"
:value="form.name"
maxlength="20"
placeholder="请输入标签名称,如:新品、招牌"
@input="
(event) =>
emit('setName', (event.target as HTMLInputElement).value)
"
/>
</div>
<div class="g-form-group">
<label class="g-form-label required">标签颜色</label>
<div class="plbl-color-grid">
<button
v-for="item in colorOptions"
:key="item.value"
type="button"
class="plbl-color-swatch"
:class="{
selected: normalizeLabelColor(form.color) === item.value,
}"
:style="{ background: item.value }"
@click="emit('setColor', item.value)"
>
<span class="plbl-check">
<IconifyIcon icon="lucide:check" />
</span>
</button>
</div>
</div>
<div class="g-form-group">
<label class="g-form-label">预览</label>
<div class="plbl-preview-area">
<span class="plbl-pill" :style="{ background: previewColor }">
{{ previewText }}
</span>
</div>
</div>
<div class="g-form-group">
<label class="g-form-label">排序</label>
<input
type="number"
min="0"
class="g-input"
:value="form.sort"
placeholder="数字越小越靠前0"
@input="
(event) =>
emit(
'setSort',
Number((event.target as HTMLInputElement).value || 0),
)
"
/>
</div>
<div class="g-form-group">
<label class="g-form-label">启用状态</label>
<div class="plbl-toggle-row">
<button
type="button"
class="g-toggle"
:class="{ on: form.status === 'enabled' }"
@click="emit('toggleStatus')"
></button>
<span class="g-toggle-label">
{{ form.status === 'enabled' ? '启用' : '停用' }}
</span>
</div>
</div>
</div>
<div class="g-drawer-ft">
<button type="button" class="g-btn" @click="closeDrawer">取消</button>
<button
type="button"
class="g-btn g-btn-primary"
:disabled="submitting"
@click="submit"
>
{{ submitText }}
</button>
</div>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
/**
* 文件职责:标签列表表格。
* 1. 展示标签预览、颜色、关联数与状态。
* 2. 对外抛出编辑、启停、删除操作事件。
*/
import type { ProductLabelTableRowViewModel } from '../types';
import { normalizeLabelColor } from '../composables/labels-page/constants';
const props = defineProps<{
rows: ProductLabelTableRowViewModel[];
}>();
const emit = defineEmits<{
disable: [item: ProductLabelTableRowViewModel];
edit: [item: ProductLabelTableRowViewModel];
enable: [item: ProductLabelTableRowViewModel];
remove: [item: ProductLabelTableRowViewModel];
}>();
function getColor(value: string) {
return normalizeLabelColor(value);
}
</script>
<template>
<table class="plbl-table">
<thead>
<tr>
<th>标签预览</th>
<th>标签名称</th>
<th>颜色</th>
<th>已关联商品</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in props.rows"
:key="item.id"
:class="{ disabled: item.status === 'disabled' }"
>
<td>
<span class="plbl-pill" :style="{ background: getColor(item.color) }">
{{ item.name }}
</span>
</td>
<td>{{ item.name }}</td>
<td>
<span
class="plbl-color-dot"
:style="{ background: getColor(item.color) }"
></span>
{{ getColor(item.color) }}
</td>
<td>{{ item.productCount }}个商品</td>
<td>
<span
class="plbl-status"
:class="
item.status === 'enabled' ? 'plbl-status-on' : 'plbl-status-off'
"
>
<span class="plbl-status-dot"></span>
{{ item.status === 'enabled' ? '启用' : '停用' }}
</span>
</td>
<td>
<button type="button" class="g-action" @click="emit('edit', item)">
编辑
</button>
<button
v-if="item.status === 'disabled'"
type="button"
class="g-action"
@click="emit('enable', item)"
>
启用
</button>
<button
v-else
type="button"
class="g-action"
@click="emit('disable', item)"
>
停用
</button>
<button
type="button"
class="g-action g-action-danger"
@click="emit('remove', item)"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
</template>

View File

@@ -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',
};
}

View File

@@ -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<boolean>;
isStoreLoading: Ref<boolean>;
rows: Ref<ProductLabelDto[]>;
selectedStoreId: Ref<string>;
stores: Ref<StoreListItemDto[]>;
}
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,
};
}

View File

@@ -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<string>;
form: LabelEditorForm;
isDrawerOpen: Ref<boolean>;
isDrawerSubmitting: Ref<boolean>;
loadLabels: () => Promise<void>;
rows: Ref<ProductLabelTableRowViewModel[]>;
selectedStoreId: Ref<string>;
}
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,
};
}

View File

@@ -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<void>;
selectedStoreId: Ref<string>;
}
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,
};
}

View File

@@ -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<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<ProductLabelTableRowViewModel[]>([]);
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<LabelEditorForm>(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,
};
}

View File

@@ -1,424 +1,120 @@
<script setup lang="ts">
/**
* 文件职责:商品标签管理页面。
* 1. 管理标签基础信息与启停状态
* 2. 管理标签关联商品
* 文件职责:商品标签管理页面主视图
* 1. 还原原型工具栏、统计条、表格卡片与右侧抽屉
* 2. 通过真实 TenantApi 完成标签增删改与启停
*/
import type { ProductSwitchStatus } from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { Button, Empty, Input, Select, Spin } from 'ant-design-vue';
import {
changeProductLabelStatusApi,
deleteProductLabelApi,
getProductLabelListApi,
saveProductLabelApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
import LabelEditorDrawer from './components/LabelEditorDrawer.vue';
import LabelListTable from './components/LabelListTable.vue';
import { LABEL_COLOR_OPTIONS } from './composables/labels-page/constants';
import { useProductLabelsPage } from './composables/useProductLabelsPage';
type StatusFilter = '' | ProductSwitchStatus;
interface LabelRow {
color: string;
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<LabelRow[]>([]);
const isLoading = ref(false);
const keyword = ref('');
const statusFilter = ref<StatusFilter>('');
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
const isDrawerOpen = ref(false);
const isDrawerSubmitting = ref(false);
const editingLabelId = ref('');
const form = reactive({
name: '',
description: '',
color: '#1677ff',
status: 'enabled' as ProductSwitchStatus,
sort: 1,
productIds: [] as string[],
});
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
];
const drawerTitle = computed(() =>
editingLabelId.value ? '编辑标签' : '新增标签',
);
/** 控制抽屉开关。 */
function setDrawerOpen(value: boolean) {
isDrawerOpen.value = value;
}
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
/** 加载标签列表。 */
async function loadLabels() {
if (!selectedStoreId.value) {
rows.value = [];
return;
}
isLoading.value = true;
try {
const result = await getProductLabelListApi({
storeId: selectedStoreId.value,
keyword: keyword.value.trim() || undefined,
status: (statusFilter.value || undefined) as
| ProductSwitchStatus
| undefined,
});
rows.value = result as LabelRow[];
} catch (error) {
console.error(error);
rows.value = [];
message.error('加载标签失败');
} finally {
isLoading.value = false;
}
}
/** 加载商品选择器。 */
async function loadPickerOptions() {
if (!selectedStoreId.value) {
pickerOptions.value = [];
return;
}
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
});
pickerOptions.value = list.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
} catch (error) {
console.error(error);
pickerOptions.value = [];
message.error('加载商品失败');
}
}
/** 重置表单。 */
function resetForm() {
editingLabelId.value = '';
form.name = '';
form.description = '';
form.color = '#1677ff';
form.status = 'enabled';
form.sort = rows.value.length + 1;
form.productIds = [];
}
/** 打开新增抽屉。 */
async function openCreateDrawer() {
resetForm();
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 打开编辑抽屉。 */
async function openEditDrawer(row: LabelRow) {
editingLabelId.value = row.id;
form.name = row.name;
form.description = row.description;
form.color = row.color;
form.status = row.status;
form.sort = row.sort;
form.productIds = [...row.productIds];
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 保存标签。 */
async function submitDrawer() {
if (!selectedStoreId.value) return;
if (!form.name.trim()) {
message.warning('请输入标签名称');
return;
}
isDrawerSubmitting.value = true;
try {
await saveProductLabelApi({
storeId: selectedStoreId.value,
id: editingLabelId.value || undefined,
name: form.name.trim(),
description: form.description.trim(),
color: form.color.trim() || '#1677ff',
status: form.status,
sort: form.sort,
productIds: [...form.productIds],
});
message.success(editingLabelId.value ? '标签已更新' : '标签已创建');
isDrawerOpen.value = false;
await loadLabels();
} catch (error) {
console.error(error);
} finally {
isDrawerSubmitting.value = false;
}
}
/** 删除标签。 */
function removeLabel(row: LabelRow) {
if (!selectedStoreId.value) return;
Modal.confirm({
title: `确认删除标签「${row.name}」吗?`,
async onOk() {
await deleteProductLabelApi({
storeId: selectedStoreId.value,
labelId: row.id,
});
message.success('标签已删除');
await loadLabels();
},
});
}
/** 切换标签状态。 */
async function toggleLabelStatus(row: LabelRow, checked: boolean) {
if (!selectedStoreId.value) return;
await changeProductLabelStatusApi({
storeId: selectedStoreId.value,
labelId: row.id,
status: checked ? 'enabled' : 'disabled',
});
row.status = checked ? 'enabled' : 'disabled';
message.success('状态已更新');
}
/** 重置筛选条件。 */
function resetFilters() {
keyword.value = '';
statusFilter.value = '';
loadLabels();
}
watch(selectedStoreId, loadLabels);
onMounted(loadStores);
const {
disableLabel,
drawerSubmitText,
drawerTitle,
enableLabel,
filteredRows,
form,
isDrawerOpen,
isDrawerSubmitting,
isLoading,
isStoreLoading,
keyword,
labelCount,
openCreateDrawer,
openEditDrawer,
relatedProductCount,
removeLabel,
selectedStoreId,
setDrawerOpen,
setFormColor,
setFormName,
setFormSort,
setKeyword,
setSelectedStoreId,
storeOptions,
submitDrawer,
toggleFormStatus,
enabledCount,
} = useProductLabelsPage();
</script>
<template>
<Page title="商品标签" content-class="space-y-4 page-product-labels">
<Card :bordered="false">
<Space wrap>
<Page title="商品标签" content-class="page-product-labels">
<div class="plbl-page">
<div class="plbl-toolbar">
<Select
v-model:value="selectedStoreId"
class="plbl-store-select"
:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
/>
<Input
v-model:value="keyword"
style="width: 220px"
placeholder="搜索标签名称"
class="plbl-search"
:value="keyword"
placeholder="搜索标签名称"
@update:value="(value) => setKeyword(String(value ?? ''))"
/>
<Select
v-model:value="statusFilter"
:options="statusOptions"
style="width: 140px"
/>
<Button type="primary" @click="loadLabels">查询</Button>
<Button @click="resetFilters">重置</Button>
<Button type="primary" @click="openCreateDrawer">新增标签</Button>
</Space>
</Card>
<span class="plbl-spacer"></span>
<Button type="primary" @click="openCreateDrawer">+ 添加标签</Button>
</div>
<Card v-if="!selectedStoreId" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<div v-if="selectedStoreId" class="plbl-stats">
<span>
标签总数 <strong>{{ labelCount }}</strong>
</span>
<span>
启用 <strong>{{ enabledCount }}</strong>
</span>
<span>
关联商品 <strong>{{ relatedProductCount }}</strong>
</span>
</div>
<Card v-else :bordered="false">
<Table
row-key="id"
:data-source="rows"
:loading="isLoading"
:pagination="false"
size="middle"
>
<Table.Column title="标签名称" key="name" :width="160">
<template #default="{ record }">
<Tag :color="record.color">{{ record.name }}</Tag>
</template>
</Table.Column>
<Table.Column title="描述" data-index="description" key="description" />
<Table.Column
title="关联商品数"
data-index="productCount"
key="productCount"
:width="100"
/>
<Table.Column title="排序" data-index="sort" key="sort" :width="80" />
<Table.Column title="状态" key="status" :width="90">
<template #default="{ record }">
<Switch
:checked="record.status === 'enabled'"
size="small"
@change="(checked) => toggleLabelStatus(record, checked === true)"
/>
</template>
</Table.Column>
<Table.Column
title="更新时间"
data-index="updatedAt"
key="updatedAt"
:width="170"
/>
<Table.Column title="操作" key="action" :width="170">
<template #default="{ record }">
<Space size="small">
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
<Button danger size="small" @click="removeLabel(record)">
删除
</Button>
</Space>
</template>
</Table.Column>
</Table>
</Card>
<div v-if="!selectedStoreId" class="plbl-empty">
暂无门店请先创建门店
</div>
<Drawer
<Spin v-else :spinning="isLoading">
<div v-if="filteredRows.length > 0" class="plbl-card">
<LabelListTable
:rows="filteredRows"
@edit="openEditDrawer"
@enable="enableLabel"
@disable="disableLabel"
@remove="removeLabel"
/>
</div>
<div v-else class="plbl-empty">
<Empty description="暂无标签" />
</div>
</Spin>
</div>
<LabelEditorDrawer
:open="isDrawerOpen"
:title="drawerTitle"
width="520"
:destroy-on-close="true"
@update:open="setDrawerOpen"
>
<Form layout="vertical">
<Form.Item label="标签名称" required>
<Input v-model:value="form.name" :maxlength="20" show-count />
</Form.Item>
<Form.Item label="标签颜色">
<Input v-model:value="form.color" placeholder="#1677ff" />
</Form.Item>
<Form.Item label="标签描述">
<Input v-model:value="form.description" :maxlength="100" show-count />
</Form.Item>
<Form.Item label="关联商品">
<Select
v-model:value="form.productIds"
mode="multiple"
:options="pickerOptions"
placeholder="请选择商品"
/>
</Form.Item>
<Space style="display: flex; width: 100%">
<Form.Item label="排序" style="flex: 1">
<InputNumber
v-model:value="form.sort"
:min="1"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="状态" style="flex: 1">
<Select
v-model:value="form.status"
:options="[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]"
/>
</Form.Item>
</Space>
</Form>
<template #footer>
<Space>
<Button @click="setDrawerOpen(false)">取消</Button>
<Button
type="primary"
:loading="isDrawerSubmitting"
@click="submitDrawer"
>
保存
</Button>
</Space>
</template>
</Drawer>
: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"
/>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:商品标签页面样式。 */
.page-product-labels {
:deep(.ant-table-cell) {
vertical-align: middle;
}
}
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
@import './base.less';
@import './layout.less';
@import './card.less';
@import './drawer.less';
@import './responsive.less';

View File

@@ -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);
}
}

View File

@@ -0,0 +1,11 @@
.page-product-labels {
@media (width <= 1200px) {
.plbl-toolbar {
flex-wrap: wrap;
}
.plbl-spacer {
display: none;
}
}
}

View File

@@ -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;