This commit is contained in:
2026-05-31 12:09:14 +08:00
commit e4f9eedb80
1121 changed files with 209380 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-input-search
v-model:value="keywords"
placeholder="搜索规格名称"
allow-clear
style="width: 200px"
@search="search"
@pressEnter="search"
/>
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon><PlusOutlined /></template>
<span>添加规格</span>
</a-button>
<a-button
danger
class="ele-btn-icon"
:disabled="!selection?.length"
@click="remove"
>
<template #icon><DeleteOutlined /></template>
<span>批量删除</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import type { ShopSpecParam } from '@/api/shop/shopSpec/model';
const props = withDefaults(
defineProps<{
selection?: any[];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: ShopSpecParam): void;
(e: 'add'): void;
(e: 'remove'): void;
}>();
const keywords = ref('');
const search = () => {
emit('search', { keywords: keywords.value || undefined });
};
const add = () => {
emit('add');
};
const remove = () => {
emit('remove');
};
</script>

View File

@@ -0,0 +1,146 @@
<!-- 规格编辑弹窗 -->
<template>
<ele-modal
:width="520"
:visible="visible"
:maskClosable="false"
:title="isUpdate ? '编辑规格' : '添加规格'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ flex: '80px' }"
:wrapper-col="{ flex: '1' }"
>
<a-form-item label="规格名称" name="specName">
<a-input
allow-clear
placeholder="请输入规格名称,如:颜色、尺码、材质"
v-model:value="form.specName"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="0">显示</a-radio>
<a-radio :value="1">隐藏</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="排序号" name="sortNumber">
<a-input-number
:min="0"
:max="9999"
class="ele-fluid"
placeholder="数值越小越靠前"
v-model:value="form.sortNumber"
/>
</a-form-item>
<a-form-item label="备注" name="comments">
<a-textarea
:rows="3"
:maxlength="200"
placeholder="可选备注说明"
v-model:value="form.comments"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import { addShopSpec, updateShopSpec } from '@/api/shop/shopSpec';
import { ShopSpec } from '@/api/shop/shopSpec/model';
import { FormInstance } from 'ant-design-vue/es/form';
const isUpdate = ref(false);
const props = defineProps<{
visible: boolean;
data?: ShopSpec | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const formRef = ref<FormInstance | null>(null);
const form = reactive<ShopSpec>({
specId: undefined,
specName: undefined,
comments: undefined,
status: 0,
sortNumber: 100
});
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules: Record<string, any> = {
specName: [
{
required: true,
type: 'string',
message: '请填写规格名称',
trigger: 'blur'
}
]
};
const save = () => {
if (!formRef.value) return;
formRef.value
.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateShopSpec : addShopSpec;
saveOrUpdate({ ...form })
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
assignObject(form, props.data);
isUpdate.value = true;
} else {
// 重置为默认值
Object.assign(form, {
specId: undefined,
specName: undefined,
comments: undefined,
status: 0,
sortNumber: 100
});
isUpdate.value = false;
}
} else {
formRef.value?.resetFields();
}
},
{ immediate: true }
);
</script>

View File

@@ -0,0 +1,372 @@
<!-- 规格值管理面板展开行内嵌 -->
<template>
<div class="spec-value-panel">
<div class="spec-value-header">
<span class="label">{{ spec.specName }}的规格值</span>
<span class="tip">点击标签编辑拖拽排序</span>
</div>
<div class="spec-value-body">
<!-- 已有规格值 tags -->
<a-spin :spinning="loading">
<div class="tags-wrap">
<template v-for="item in values" :key="item.specValueId">
<a-tag
:closable="true"
class="spec-tag"
@click="openEditValue(item)"
@close.prevent="removeValue(item)"
>
<span v-if="item.image" class="tag-img-wrap">
<img :src="item.image" class="tag-img" alt="" />
</span>
{{ item.specValue }}
<span v-if="item.sortNumber !== undefined" class="tag-sort">{{ item.sortNumber }}</span>
</a-tag>
</template>
<!-- 添加输入框 -->
<template v-if="addingInput">
<a-input
ref="inputRef"
v-model:value="newValueText"
size="small"
class="tag-input"
placeholder="输入规格值后回车"
@blur="confirmAdd"
@keyup.enter="confirmAdd"
@keyup.esc="cancelAdd"
/>
</template>
<template v-else>
<a-tag
class="add-tag"
@click="startAdd"
>
<PlusOutlined />
添加规格值
</a-tag>
</template>
</div>
</a-spin>
</div>
<!-- 规格值编辑弹窗点击已有 tag -->
<a-modal
v-model:open="showValueEdit"
title="编辑规格值"
:width="420"
:maskClosable="false"
@ok="saveValueEdit"
@cancel="showValueEdit = false"
>
<a-form
ref="valueFormRef"
:model="editingValue"
:rules="valueRules"
:label-col="{ flex: '80px' }"
:wrapper-col="{ flex: '1' }"
style="margin-top: 16px"
>
<a-form-item label="规格值" name="specValue">
<a-input
v-model:value="editingValue.specValue"
placeholder="如红色、XL、棉"
allow-clear
/>
</a-form-item>
<a-form-item label="图片" name="image">
<div class="value-image-row">
<a-image
v-if="editingValue.image"
:src="editingValue.image"
:width="56"
:height="56"
style="object-fit: cover; border-radius: 4px; margin-right: 8px"
/>
<a-upload
:show-upload-list="false"
accept="image/*"
:before-upload="(file: File) => handleImageUpload(file)"
>
<a-button size="small">
<template #icon><UploadOutlined /></template>
{{ editingValue.image ? '更换图片' : '上传图片' }}
</a-button>
</a-upload>
<a-button
v-if="editingValue.image"
size="small"
danger
style="margin-left: 6px"
@click="editingValue.image = ''"
>删除</a-button>
</div>
<div class="ele-text-secondary" style="font-size: 12px; margin-top: 4px">
适用于颜色类规格,上传颜色色板图片
</div>
</a-form-item>
<a-form-item label="排序号" name="sortNumber">
<a-input-number
v-model:value="editingValue.sortNumber"
:min="0"
:max="9999"
class="ele-fluid"
placeholder="数值越小越靠前"
/>
</a-form-item>
<a-form-item label="备注" name="comments">
<a-input v-model:value="editingValue.comments" placeholder="可选" allow-clear />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, nextTick } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { PlusOutlined, UploadOutlined } from '@ant-design/icons-vue';
import type { ShopSpec } from '@/api/shop/shopSpec/model';
import type { ShopSpecValue } from '@/api/shop/shopSpecValue/model';
import {
listShopSpecValue,
addShopSpecValue,
updateShopSpecValue,
removeShopSpecValue
} from '@/api/shop/shopSpecValue';
import { uploadFile } from '@/api/system/file';
const props = defineProps<{
spec: ShopSpec;
}>();
const loading = ref(false);
const values = ref<ShopSpecValue[]>([]);
// 快捷添加
const addingInput = ref(false);
const newValueText = ref('');
const inputRef = ref<any>(null);
// 编辑弹窗
const showValueEdit = ref(false);
const valueFormRef = ref<any>(null);
const editingValue = reactive<ShopSpecValue>({
specValueId: undefined,
specId: undefined,
specValue: '',
image: '',
comments: '',
sortNumber: 100
});
const valueRules: Record<string, any> = {
specValue: [{ required: true, message: '请填写规格值', trigger: 'blur' }]
};
// 加载规格值列表
const loadValues = async () => {
if (!props.spec.specId) return;
loading.value = true;
try {
const data = await listShopSpecValue({ specId: props.spec.specId } as any);
values.value = data || [];
} catch (e: any) {
message.error(e.message || '加载规格值失败');
} finally {
loading.value = false;
}
};
watch(
() => props.spec.specId,
(id) => {
if (id) loadValues();
},
{ immediate: true }
);
// 快捷添加 - 开始
const startAdd = () => {
addingInput.value = true;
newValueText.value = '';
nextTick(() => {
inputRef.value?.focus();
});
};
// 快捷添加 - 确认
const confirmAdd = async () => {
const val = newValueText.value.trim();
if (!val) {
cancelAdd();
return;
}
try {
await addShopSpecValue({
specId: props.spec.specId,
specValue: val,
sortNumber: 100
});
message.success('添加成功');
newValueText.value = '';
addingInput.value = false;
loadValues();
} catch (e: any) {
message.error(e.message || '添加失败');
}
};
// 快捷添加 - 取消
const cancelAdd = () => {
addingInput.value = false;
newValueText.value = '';
};
// 打开编辑弹窗
const openEditValue = (item: ShopSpecValue) => {
Object.assign(editingValue, {
specValueId: item.specValueId,
specId: item.specId,
specValue: item.specValue,
image: item.image || '',
comments: item.comments || '',
sortNumber: item.sortNumber ?? 100
});
showValueEdit.value = true;
};
// 保存编辑
const saveValueEdit = async () => {
try {
await valueFormRef.value?.validate();
await updateShopSpecValue({ ...editingValue });
message.success('修改成功');
showValueEdit.value = false;
loadValues();
} catch (e: any) {
if (e?.message) message.error(e.message);
}
};
// 删除规格值
const removeValue = (item: ShopSpecValue) => {
Modal.confirm({
title: '提示',
content: `确定要删除规格值「${item.specValue}」吗?`,
maskClosable: true,
onOk: async () => {
try {
const msg = await removeShopSpecValue(item.specValueId);
message.success(msg);
loadValues();
} catch (e: any) {
message.error(e.message || '删除失败');
}
}
});
};
// 图片上传
const handleImageUpload = async (file: File) => {
try {
const res = await uploadFile(file);
editingValue.image = res.path;
message.success('上传成功');
} catch (e: any) {
message.error(e.message || '上传失败');
}
return false; // 阻止 ant-design-vue 自动上传
};
</script>
<style lang="less" scoped>
.spec-value-panel {
padding: 12px 16px 8px 40px;
background: #fafafa;
border-radius: 4px;
.spec-value-header {
margin-bottom: 10px;
.label {
font-weight: 500;
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
}
.tip {
margin-left: 10px;
font-size: 12px;
color: #999;
}
}
.spec-value-body {
.tags-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-height: 36px;
}
.spec-tag {
cursor: pointer;
font-size: 13px;
padding: 4px 10px;
display: inline-flex;
align-items: center;
gap: 4px;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
.tag-img-wrap {
display: inline-flex;
align-items: center;
.tag-img {
width: 18px;
height: 18px;
border-radius: 2px;
object-fit: cover;
}
}
.tag-sort {
font-size: 11px;
color: #999;
margin-left: 2px;
background: #f0f0f0;
border-radius: 2px;
padding: 0 3px;
}
}
.add-tag {
cursor: pointer;
border-style: dashed;
color: #1890ff;
border-color: #1890ff;
background: #e6f7ff;
font-size: 13px;
padding: 4px 10px;
&:hover {
background: #bae7ff;
}
}
.tag-input {
width: 120px;
font-size: 13px;
}
}
}
.value-image-row {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,263 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="specId"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
v-model:selection="selection"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-switch
:checked="record.status === 0"
checked-children="显示"
un-checked-children="隐藏"
size="small"
@change="(val: boolean) => toggleStatus(record, val)"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要同步更新吗?这将把该规格的最新值更新到所有使用该模板的商品中。"
@confirm="syncSpec(record)"
>
<a class="ele-text-success">同步更新</a>
</a-popconfirm>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此规格吗?删除后相关商品规格数据将受影响!"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
<!-- 展开行规格值管理面板 -->
<template #expandedRowRender="{ record }">
<SpecValuePanel :spec="record" />
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<ShopSpecEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import ShopSpecEdit from './components/shopSpecEdit.vue';
import SpecValuePanel from './components/specValuePanel.vue';
import {
pageShopSpec,
removeShopSpec,
removeBatchShopSpec,
updateShopSpec,
syncGoodsSpec
} from '@/api/shop/shopSpec';
import type { ShopSpec, ShopSpecParam } from '@/api/shop/shopSpec/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<ShopSpec[]>([]);
// 当前编辑数据
const current = ref<ShopSpec | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 表格数据源
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageShopSpec({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'specId',
key: 'specId',
align: 'center',
width: 70
},
{
title: '规格名称',
dataIndex: 'specName',
key: 'specName',
align: 'left',
minWidth: 160
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 90
},
{
title: '排序',
dataIndex: 'sortNumber',
key: 'sortNumber',
align: 'center',
width: 80,
sorter: true
},
{
title: '备注',
dataIndex: 'comments',
key: 'comments',
align: 'left',
ellipsis: true
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 120,
sorter: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd')
},
{
title: '操作',
key: 'action',
width: 140,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ShopSpecParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: ShopSpec) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 切换显示/隐藏状态 */
const toggleStatus = (row: ShopSpec, val: boolean) => {
const newStatus = val ? 0 : 1;
updateShopSpec({ ...row, status: newStatus })
.then((msg) => {
message.success(msg);
row.status = newStatus;
})
.catch((e) => {
message.error(e.message);
});
};
/* 删除单个 */
const remove = (row: ShopSpec) => {
const hide = message.loading('请求中..', 0);
removeShopSpec(row.specId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 同步更新商品规格 */
const syncSpec = (row: ShopSpec) => {
const hide = message.loading('正在同步更新..', 0);
syncGoodsSpec(row.specId!)
.then((res) => {
hide();
const updatedCount = res?.data?.updatedCount ?? 0;
message.success(`同步完成,已更新 ${updatedCount} 个商品`);
})
.catch((e) => {
hide();
message.error(e.message || '同步失败');
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的规格吗?删除后相关商品规格数据将受影响!',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchShopSpec(selection.value.map((d) => d.specId))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 自定义行属性 */
const customRow = (record: ShopSpec) => {
return {
onDblclick: () => {
openEdit(record);
}
};
};
</script>
<script lang="ts">
export default {
name: 'ShopSpec'
};
</script>
<style lang="less" scoped></style>