feat(project): restore product labels page and split modules
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
190
apps/web-antd/src/views/product/labels/styles/base.less
Normal file
190
apps/web-antd/src/views/product/labels/styles/base.less
Normal 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;
|
||||
}
|
||||
92
apps/web-antd/src/views/product/labels/styles/card.less
Normal file
92
apps/web-antd/src/views/product/labels/styles/card.less
Normal 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;
|
||||
}
|
||||
}
|
||||
106
apps/web-antd/src/views/product/labels/styles/drawer.less
Normal file
106
apps/web-antd/src/views/product/labels/styles/drawer.less
Normal 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;
|
||||
}
|
||||
}
|
||||
5
apps/web-antd/src/views/product/labels/styles/index.less
Normal file
5
apps/web-antd/src/views/product/labels/styles/index.less
Normal file
@@ -0,0 +1,5 @@
|
||||
@import './base.less';
|
||||
@import './layout.less';
|
||||
@import './card.less';
|
||||
@import './drawer.less';
|
||||
@import './responsive.less';
|
||||
81
apps/web-antd/src/views/product/labels/styles/layout.less
Normal file
81
apps/web-antd/src/views/product/labels/styles/layout.less
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.page-product-labels {
|
||||
@media (width <= 1200px) {
|
||||
.plbl-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plbl-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/web-antd/src/views/product/labels/types.ts
Normal file
21
apps/web-antd/src/views/product/labels/types.ts
Normal 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;
|
||||
Reference in New Issue
Block a user