add sku mapping

This commit is contained in:
2026-04-14 13:45:28 +08:00
parent b647680576
commit b1cd4ea0eb
16 changed files with 3176 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
<script setup lang="ts">
import { useApiKeyStore, MAX_KEYS_PER_USER } from '@/stores/api-key'
import { useUserStore } from '@/stores/user'
import {
PlusOutlined,
CopyOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import type { ApiKeyRecord } from '@/types/api'
import dayjs from 'dayjs'
const apiKeyStore = useApiKeyStore()
const userStore = useUserStore()
const apiKeyEnabled = computed(() => userStore.user?.api_key_enabled ?? false)
const hasKeys = computed(() => apiKeyStore.keys.length > 0)
// Create modal
const createModalOpen = ref(false)
const createLoading = ref(false)
const createForm = reactive({
name: '',
expires_at: null as string | null,
})
// Plain key modal
const plainKeyModalOpen = ref(false)
const plainKey = ref('')
function isExpired(record: ApiKeyRecord): boolean {
return !!record.expires_at && dayjs(record.expires_at).isBefore(dayjs())
}
function formatTime(time: string | null): string {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 19)
}
const columns = [
{ title: '名称', dataIndex: 'name', width: 150 },
{ title: '前缀', dataIndex: 'key_prefix', width: 120 },
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
{ title: '过期时间', key: 'expires_at', width: 170 },
{ title: '最后使用', dataIndex: 'last_used_at', width: 170 },
{ title: '状态', key: 'enabled', width: 100 },
{ title: '操作', key: 'action', width: 80 },
]
const readonlyColumns = columns.filter((c) => c.key !== 'enabled' && c.key !== 'action')
function showCreateModal() {
createForm.name = ''
createForm.expires_at = null
createModalOpen.value = true
}
async function handleCreate() {
if (!createForm.name.trim()) {
message.warning('请输入 API Key 名称')
return
}
createLoading.value = true
try {
const result = await apiKeyStore.createKey({
name: createForm.name.trim(),
expires_at: createForm.expires_at || undefined,
})
if (result) {
createModalOpen.value = false
plainKey.value = result.plain_key
plainKeyModalOpen.value = true
}
} finally {
createLoading.value = false
}
}
async function handleToggle(record: ApiKeyRecord, checked: boolean) {
await apiKeyStore.toggleKey(record.id, checked)
}
async function handleDelete(record: ApiKeyRecord) {
await apiKeyStore.deleteKey(record.id)
}
function handleCopyKey() {
navigator.clipboard.writeText(plainKey.value).then(() => {
message.success('已复制')
})
}
function handlePlainKeyModalClose() {
plainKey.value = ''
plainKeyModalOpen.value = false
}
onMounted(() => {
apiKeyStore.fetchMyKeys()
})
</script>
<template>
<!-- Mode 3: api_key_enabled=false 且无 Key -->
<a-empty
v-if="!apiKeyEnabled && !hasKeys"
description="API Key 功能未启用,请联系管理员开启"
/>
<!-- Mode 4: api_key_enabled=false 且有 Key 警告 + 只读表格 -->
<div v-else-if="!apiKeyEnabled && hasKeys">
<a-alert
type="warning"
show-icon
class="mb-4"
message="API Key 功能已被管理员关闭。您的所有 API Key 当前无法用于认证。已有 Key 不会被删除,功能重新开启后将自动恢复。如需开启,请联系管理员。"
/>
<a-table
:columns="readonlyColumns"
:data-source="apiKeyStore.keys"
:loading="apiKeyStore.loading"
:pagination="false"
row-key="id"
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'expires_at'">
<span v-if="!record.expires_at">永不过期</span>
<span v-else>
<span :class="{ 'text-red-500': isExpired(record) }">
{{ formatTime(record.expires_at) }}
</span>
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
</span>
</template>
<template v-else-if="column.dataIndex === 'key_prefix'">
{{ record.key_prefix }}****
</template>
<template v-else-if="column.dataIndex === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.dataIndex === 'last_used_at'">
{{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }}
</template>
</template>
</a-table>
</div>
<!-- Mode 2: api_key_enabled=true 且无 Key — 引导空状态 -->
<div v-else-if="apiKeyEnabled && !hasKeys && !apiKeyStore.loading">
<a-empty description="还没有 API Key">
<a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template>
生成第一个 API Key
</a-button>
</a-empty>
</div>
<!-- Mode 1: api_key_enabled=true 且有 Key — 完整面板 -->
<div v-else-if="apiKeyEnabled">
<div class="flex justify-between items-center mb-3">
<a-tag>{{ apiKeyStore.keyCount }}/{{ MAX_KEYS_PER_USER }}</a-tag>
<a-tooltip
v-if="!apiKeyStore.canCreate"
title="已达上限请删除不需要的 Key 后再生成"
>
<a-button type="primary" disabled>
<template #icon><PlusOutlined /></template>
生成 API Key
</a-button>
</a-tooltip>
<a-button
v-else
type="primary"
@click="showCreateModal"
>
<template #icon><PlusOutlined /></template>
生成 API Key
</a-button>
</div>
<a-table
:columns="columns"
:data-source="apiKeyStore.keys"
:loading="apiKeyStore.loading"
:pagination="false"
row-key="id"
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'key_prefix'">
{{ record.key_prefix }}****
</template>
<template v-else-if="column.dataIndex === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'expires_at'">
<span v-if="!record.expires_at">永不过期</span>
<span v-else>
<span :class="{ 'text-red-500': isExpired(record) }">
{{ formatTime(record.expires_at) }}
</span>
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
</span>
</template>
<template v-else-if="column.dataIndex === 'last_used_at'">
{{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }}
</template>
<template v-else-if="column.key === 'enabled'">
<a-switch
:checked="record.enabled"
:disabled="isExpired(record)"
size="small"
@change="(checked: boolean) => handleToggle(record, checked)"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm
title="确定要删除此 API Key "
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</div>
<!-- Loading state -->
<a-spin v-if="apiKeyStore.loading && !hasKeys && apiKeyEnabled" class="mt-4" />
<!-- Create Modal -->
<a-modal
v-model:open="createModalOpen"
title="生成 API Key"
:confirm-loading="createLoading"
@ok="handleCreate"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input
v-model:value="createForm.name"
placeholder="例如数据同步脚本监控系统"
:maxlength="100"
@press-enter="handleCreate"
/>
</a-form-item>
<a-form-item label="过期时间">
<a-date-picker
v-model:value="createForm.expires_at"
value-format="YYYY-MM-DD HH:mm:ss"
show-time
placeholder="留空表示永不过期"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- Plain Key Modal -->
<a-modal
:open="plainKeyModalOpen"
title="API Key 已生成"
:footer="null"
:mask-closable="false"
@cancel="handlePlainKeyModalClose"
>
<a-alert
type="warning"
show-icon
class="mb-4"
message="此密钥仅显示一次关闭后无法再次查看请妥善保存"
/>
<div class="bg-gray-100 p-3 rounded font-mono text-sm break-all select-all mb-3">
{{ plainKey }}
</div>
<a-button type="primary" block @click="handleCopyKey">
<template #icon><CopyOutlined /></template>
复制 API Key
</a-button>
</a-modal>
</template>
<style scoped>
:deep(.expired-row) {
opacity: 0.5;
}
</style>
@@ -20,6 +20,8 @@ import {
FileSearchOutlined,
ApiOutlined,
HistoryOutlined,
BarcodeOutlined,
LinkOutlined,
} from '@ant-design/icons-vue'
import type { Component } from 'vue'
@@ -53,6 +55,7 @@ const initOpenKeys = () => {
const path = route.path
if (path.startsWith('/order')) openKeys.value = ['orders-group']
else if (path.startsWith('/refund')) openKeys.value = ['refunds-group']
else if (path.startsWith('/sku-')) openKeys.value = ['sku-group']
else if (path.startsWith('/logs')) openKeys.value = ['logs-group']
}
@@ -64,6 +67,15 @@ const menuItems: MenuItem[] = [
{ key: '/', icon: DashboardOutlined, label: '首页' },
{ key: '/users', icon: UserOutlined, label: '用户管理', adminOnly: true },
{ key: '/products', icon: ShoppingOutlined, label: '产品管理' },
{
key: 'sku-group',
icon: BarcodeOutlined,
label: 'SKU 管理',
children: [
{ key: '/sku-origins', icon: BarcodeOutlined, label: '内部 SKU' },
{ key: '/sku-mappings', icon: LinkOutlined, label: 'SKU 映射' },
],
},
{
key: 'orders-group',
icon: FileTextOutlined,
@@ -126,6 +138,8 @@ const breadcrumbItems = computed(() => {
'/order-items': '订单子项',
'/refunds': '退款列表',
'/refund-items': '退款子项',
'/sku-origins': '内部 SKU',
'/sku-mappings': 'SKU 映射',
'/roles': '角色管理',
'/route-groups': '路由组管理',
'/mq-status': '队列监控',
+574
View File
@@ -0,0 +1,574 @@
<script setup lang="ts">
import {
useSkuMappingStore,
type SkuMappingForm,
type GenerateSkuParams,
} from '@/stores/sku-mapping'
import CascadeFilter from '@/components/CascadeFilter.vue'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
ThunderboltOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons-vue'
import type { Rule } from 'ant-design-vue/es/form'
const store = useSkuMappingStore()
const columns = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '公司', key: 'company', width: 120 },
{ title: '平台', key: 'platform', width: 100 },
{ title: '店铺', key: 'store', width: 140 },
{ title: '内部 SKU', dataIndex: 'origin_sku', width: 140 },
{ title: '平台 SKU', dataIndex: 'platform_outer_sku', width: 160 },
{ title: '平台商品ID', dataIndex: 'platform_product_id', width: 160, ellipsis: true },
{ title: '状态', key: 'enabled', width: 80 },
{ title: '更新时间', key: 'updated_at', width: 170 },
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const },
]
// Modal state
const modalVisible = ref(false)
const modalTitle = ref('新建映射')
const editingId = ref<number | null>(null)
const formRef = ref()
const saving = ref(false)
const defaultForm = (): SkuMappingForm => ({
company_id: undefined,
platform_id: undefined,
store_id: undefined,
origin_sku: '',
origin_sku_id: undefined,
platform_product_id: '',
platform_outer_sku: '',
generation_strategy: 'prefix',
warehouse_id: undefined,
enabled: true,
note: '',
})
const form = reactive<SkuMappingForm>(defaultForm())
const rules: Record<string, Rule[]> = {
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
platform_id: [{ required: true, message: '请选择平台', trigger: 'change' }],
origin_sku: [{ required: true, message: '请输入内部 SKU', trigger: 'blur' }],
platform_outer_sku: [{ required: true, message: '请输入或生成平台 SKU', trigger: 'blur' }],
}
// Generation modal
const genModalVisible = ref(false)
const genLoading = ref(false)
const genForm = reactive({
strategy: 'prefix' as 'prefix' | 'prefix_random' | 'manual',
prefix: '',
random_length: 4,
manual_value: '',
})
const genResult = ref('')
// Filtered store options based on cascade
const filteredStoreOptions = computed(() => {
let filtered = store.stores
if (form.company_id) {
filtered = filtered.filter((s) => s.company_id === form.company_id)
}
if (form.platform_id) {
filtered = filtered.filter((s) => s.platform_id === form.platform_id)
}
return filtered.map((s) => ({
value: s.id,
label: s.label && s.label !== 'null' ? s.label : s.name,
}))
})
// Filtered SKU origins by company
const filteredSkuOrigins = computed(() => {
if (!form.company_id) return store.skuOrigins
return store.skuOrigins.filter((s) => s.company_id === form.company_id)
})
onMounted(() => {
store.loadLookups()
store.loadSkuOrigins()
store.fetchItems()
})
function handleSearch() {
store.pagination.page = 1
store.fetchItems()
}
function handleReset() {
store.resetFilters()
store.fetchItems()
}
function handlePageChange(page: number, pageSize: number) {
store.pagination.page = page
store.pagination.per_page = pageSize
store.fetchItems()
}
function formatTime(time: string | null) {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 16)
}
function openCreate() {
Object.assign(form, defaultForm())
editingId.value = null
modalTitle.value = '新建映射'
modalVisible.value = true
}
function openEdit(record: SkuMappingForm & { id: number }) {
editingId.value = record.id
modalTitle.value = '编辑映射'
Object.assign(form, {
company_id: record.company_id,
platform_id: record.platform_id,
store_id: record.store_id || undefined,
origin_sku: record.origin_sku || '',
origin_sku_id: record.origin_sku_id || undefined,
platform_product_id: record.platform_product_id || '',
platform_outer_sku: record.platform_outer_sku || '',
generation_strategy: record.generation_strategy || 'prefix',
warehouse_id: record.warehouse_id || undefined,
enabled: record.enabled ?? true,
note: record.note || '',
})
modalVisible.value = true
}
async function handleSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
if (editingId.value) {
await store.updateItem(editingId.value, { ...form })
} else {
await store.createItem({ ...form })
}
modalVisible.value = false
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
} finally {
saving.value = false
}
}
function handleDelete(record: { id: number; origin_sku: string; platform_outer_sku: string | null }) {
Modal.confirm({
title: '确认删除',
content: `确定删除映射「${record.origin_sku}${record.platform_outer_sku || '-'}」?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await store.deleteItem(record.id)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '删除失败'
message.error(msg)
}
},
})
}
function openGenerate() {
if (!form.origin_sku_id) {
message.warning('请先选择内部 SKU')
return
}
if (!form.platform_id) {
message.warning('请先选择平台')
return
}
genForm.strategy = 'prefix'
genForm.prefix = ''
genForm.random_length = 4
genForm.manual_value = ''
genResult.value = ''
genModalVisible.value = true
}
async function handleGenerate() {
if (genForm.strategy === 'manual' && !genForm.manual_value) {
message.warning('请输入手动 SKU 值')
return
}
if ((genForm.strategy === 'prefix' || genForm.strategy === 'prefix_random') && !genForm.prefix) {
message.warning('请输入前缀')
return
}
genLoading.value = true
try {
const params: GenerateSkuParams = {
origin_sku_id: form.origin_sku_id!,
platform_id: form.platform_id!,
strategy: genForm.strategy,
prefix: genForm.prefix,
random_length: genForm.random_length,
manual_value: genForm.manual_value,
}
const result = await store.generateSku(params)
genResult.value = result.generated_sku
if (result.has_duplicate) {
message.warning(result.message || '该平台已存在相同 SKU 映射')
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '生成失败'
message.error(msg)
} finally {
genLoading.value = false
}
}
function applyGenerated() {
if (genResult.value) {
form.platform_outer_sku = genResult.value
form.generation_strategy = genForm.strategy
genModalVisible.value = false
message.success('已填入生成的平台 SKU')
}
}
// Watch company change to reload SKU origins
watch(
() => form.company_id,
(val) => {
store.loadSkuOrigins(val)
},
)
</script>
<template>
<div>
<h2 class="text-xl font-semibold mb-4">SKU 映射管理</h2>
<!-- Filter area -->
<a-card class="mb-6">
<a-form layout="inline" class="filter-form" @submit.prevent="handleSearch">
<a-form-item>
<CascadeFilter v-model="store.cascadeValue" />
</a-form-item>
<a-form-item label="内部 SKU">
<a-input
v-model:value="store.filters.origin_sku"
placeholder="内部 SKU"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="平台 SKU">
<a-input
v-model:value="store.filters.platform_outer_sku"
placeholder="平台侧 SKU"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="store.filters.enabled"
placeholder="全部"
allow-clear
style="width: 100px"
>
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- Table -->
<a-card>
<div class="mb-4 flex justify-between items-center">
<span class="text-gray-500"> {{ store.pagination.total }} 条记录</span>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
新建映射
</a-button>
</div>
<a-table
:columns="columns"
:data-source="store.items"
:loading="store.loading"
:pagination="false"
row-key="id"
:scroll="{ x: 1400 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'company'">
{{ store.companyMap.get(record.company_id) || record.company_id }}
</template>
<template v-else-if="column.key === 'platform'">
{{ store.platformMap.get(record.platform_id) || record.platform_id }}
</template>
<template v-else-if="column.key === 'store'">
<template v-if="record.store_id">
{{ store.storeMap.get(record.store_id) || record.store_id }}
</template>
<span v-else class="text-gray-400">平台默认</span>
</template>
<template v-else-if="column.key === 'enabled'">
<a-tag :color="record.enabled ? 'green' : 'default'">
{{ record.enabled ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'updated_at'">
{{ formatTime(record.updated_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="link" danger size="small" @click="handleDelete(record)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<div class="mt-4 flex justify-end">
<a-pagination
:current="store.pagination.page"
:page-size="store.pagination.per_page"
:total="store.pagination.total"
show-size-changer
show-quick-jumper
:show-total="(total: number) => `${total}`"
@change="handlePageChange"
/>
</div>
</a-card>
<!-- Create/Edit Modal -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="saving"
@ok="handleSubmit"
@cancel="modalVisible = false"
:width="640"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
class="mt-4"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="公司" name="company_id">
<a-select
v-model:value="form.company_id"
placeholder="请选择公司"
:options="store.companies.map(c => ({
value: c.id,
label: (c.label && c.label !== 'null') ? c.label : c.name
}))"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="平台" name="platform_id">
<a-select
v-model:value="form.platform_id"
placeholder="请选择平台"
:options="store.platforms.map(p => ({
value: p.id,
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`)
}))"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="店铺" name="store_id">
<a-select
v-model:value="form.store_id"
placeholder="留空表示平台默认"
:options="filteredStoreOptions"
allow-clear
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="内部 SKU" name="origin_sku">
<a-select
v-model:value="form.origin_sku_id"
placeholder="选择已有 SKU"
:options="filteredSkuOrigins.map(s => ({
value: s.id,
label: `${s.sku} - ${s.name}`
}))"
allow-clear
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
@change="(val: number | undefined) => {
const found = store.skuOrigins.find(s => s.id === val)
form.origin_sku = found?.sku || ''
}"
/>
<a-input
v-if="!form.origin_sku_id"
v-model:value="form.origin_sku"
placeholder="或手动输入内部 SKU"
class="mt-2"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item name="platform_outer_sku">
<template #label>
<span>
平台 SKU
<a-tooltip title="电商平台侧使用的 SKU 编码需符合平台填写规则如不能以 0 开头不能含特殊字符等">
<QuestionCircleOutlined class="ml-1 text-gray-400" />
</a-tooltip>
</span>
</template>
<a-input v-model:value="form.platform_outer_sku" placeholder="平台侧 SKU 编码">
<template #addonAfter>
<a-button type="link" size="small" @click="openGenerate" :style="{ padding: 0 }">
<ThunderboltOutlined /> 自动生成
</a-button>
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="状态">
<a-switch v-model:checked="form.enabled" checked-children="启用" un-checked-children="禁用" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="平台商品 ID" name="platform_product_id">
<a-input v-model:value="form.platform_product_id" placeholder="可选" />
</a-form-item>
<a-form-item label="备注" name="note">
<a-textarea v-model:value="form.note" placeholder="可选备注" :rows="2" />
</a-form-item>
</a-form>
</a-modal>
<!-- SKU Generation Modal -->
<a-modal
v-model:open="genModalVisible"
title="自动生成平台 SKU"
:width="480"
@cancel="genModalVisible = false"
>
<div class="mt-4">
<a-form layout="vertical">
<a-form-item label="生成策略">
<a-radio-group v-model:value="genForm.strategy">
<a-radio-button value="prefix">前缀模式</a-radio-button>
<a-radio-button value="prefix_random">前缀+随机</a-radio-button>
<a-radio-button value="manual">手动输入</a-radio-button>
</a-radio-group>
</a-form-item>
<template v-if="genForm.strategy === 'prefix' || genForm.strategy === 'prefix_random'">
<a-form-item label="前缀">
<a-input v-model:value="genForm.prefix" placeholder=" C03" />
<div class="text-xs text-gray-400 mt-1">
前缀模式:生成 {前缀}_{内部SKU},如 C03_0032
</div>
</a-form-item>
</template>
<template v-if="genForm.strategy === 'prefix_random'">
<a-form-item label="随机部分长度">
<a-input-number v-model:value="genForm.random_length" :min="2" :max="8" />
<div class="text-xs text-gray-400 mt-1">
前缀+随机模式:生成 {前缀}_{随机字母数字},如 C03_A7K2
</div>
</a-form-item>
</template>
<template v-if="genForm.strategy === 'manual'">
<a-form-item label="手动输入">
<a-input v-model:value="genForm.manual_value" placeholder="手动输入 SKU " />
</a-form-item>
</template>
<a-form-item>
<a-button type="primary" :loading="genLoading" @click="handleGenerate">
<ThunderboltOutlined /> 生成预览
</a-button>
</a-form-item>
<a-form-item v-if="genResult" label="生成结果">
<a-alert type="success" show-icon>
<template #message>
<span class="font-mono text-base">{{ genResult }}</span>
</template>
</a-alert>
</a-form-item>
</a-form>
</div>
<template #footer>
<a-button @click="genModalVisible = false">取消</a-button>
<a-button type="primary" :disabled="!genResult" @click="applyGenerated">
使用此 SKU
</a-button>
</template>
</a-modal>
</div>
</template>
<style scoped>
.filter-form :deep(.ant-form-item) {
margin-bottom: 12px;
}
</style>
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { useSkuOriginStore, type SkuOriginForm } from '@/stores/sku-origin'
import CascadeFilter from '@/components/CascadeFilter.vue'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import type { Rule } from 'ant-design-vue/es/form'
const store = useSkuOriginStore()
const columns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '公司', key: 'company', width: 140 },
{ title: 'SKU', dataIndex: 'sku', width: 160 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '条形码', dataIndex: 'barcode', width: 160 },
{ title: '标签', dataIndex: 'label', width: 120 },
{ title: 'HS 编码', dataIndex: 'hs', width: 120 },
{ title: '更新时间', key: 'updated_at', width: 170 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
]
// Modal state
const modalVisible = ref(false)
const modalTitle = ref('新建内部 SKU')
const editingId = ref<number | null>(null)
const formRef = ref()
const saving = ref(false)
const defaultForm = (): SkuOriginForm => ({
company_id: undefined,
sku: '',
barcode: '',
name: '',
label: '',
hs: '',
ledger: '',
})
const form = reactive<SkuOriginForm>(defaultForm())
const rules: Record<string, Rule[]> = {
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
sku: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
}
onMounted(() => {
store.loadLookups()
store.fetchItems()
})
function handleSearch() {
store.pagination.page = 1
store.fetchItems()
}
function handleReset() {
store.resetFilters()
store.fetchItems()
}
function handlePageChange(page: number, pageSize: number) {
store.pagination.page = page
store.pagination.per_page = pageSize
store.fetchItems()
}
function formatTime(time: string | null) {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 16)
}
function openCreate() {
Object.assign(form, defaultForm())
editingId.value = null
modalTitle.value = '新建内部 SKU'
modalVisible.value = true
}
function openEdit(record: { id: number; company_id: number; sku: string; barcode: string; name: string; label: string | null; hs: string | null }) {
editingId.value = record.id
modalTitle.value = '编辑内部 SKU'
Object.assign(form, {
company_id: record.company_id,
sku: record.sku,
barcode: record.barcode || '',
name: record.name || '',
label: record.label || '',
hs: record.hs || '',
ledger: '',
})
modalVisible.value = true
}
async function handleSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
if (editingId.value) {
await store.updateItem(editingId.value, { ...form })
} else {
await store.createItem({ ...form })
}
modalVisible.value = false
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
} finally {
saving.value = false
}
}
function handleDelete(record: { id: number; sku: string }) {
Modal.confirm({
title: '确认删除',
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被映射引用将无法删除。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await store.deleteItem(record.id)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '删除失败'
message.error(msg)
}
},
})
}
</script>
<template>
<div>
<h2 class="text-xl font-semibold mb-4">内部 SKU 管理</h2>
<!-- Filter area -->
<a-card class="mb-6">
<a-form layout="inline" class="filter-form" @submit.prevent="handleSearch">
<a-form-item>
<CascadeFilter v-model="store.cascadeValue" />
</a-form-item>
<a-form-item label="SKU">
<a-input
v-model:value="store.filters.sku"
placeholder="SKU 编码"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="名称">
<a-input
v-model:value="store.filters.name"
placeholder="模糊搜索"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="条形码">
<a-input
v-model:value="store.filters.barcode"
placeholder="条形码"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- Table -->
<a-card>
<div class="mb-4 flex justify-between items-center">
<span class="text-gray-500"> {{ store.pagination.total }} 条记录</span>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
新建 SKU
</a-button>
</div>
<a-table
:columns="columns"
:data-source="store.items"
:loading="store.loading"
:pagination="false"
row-key="id"
:scroll="{ x: 1200 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'company'">
{{ store.companyMap.get(record.company_id) || record.company_id }}
</template>
<template v-else-if="column.key === 'updated_at'">
{{ formatTime(record.updated_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="link" danger size="small" @click="handleDelete(record)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<div class="mt-4 flex justify-end">
<a-pagination
:current="store.pagination.page"
:page-size="store.pagination.per_page"
:total="store.pagination.total"
show-size-changer
show-quick-jumper
:show-total="(total: number) => `${total}`"
@change="handlePageChange"
/>
</div>
</a-card>
<!-- Create/Edit Modal -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="saving"
@ok="handleSubmit"
@cancel="modalVisible = false"
:width="560"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
class="mt-4"
>
<a-form-item label="公司" name="company_id">
<a-select
v-model:value="form.company_id"
placeholder="请选择公司"
:options="store.companies.map(c => ({
value: c.id,
label: (c.label && c.label !== 'null') ? c.label : c.name
}))"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="SKU 编码" name="sku">
<a-input v-model:value="form.sku" placeholder="客户内部 SKU 编码" />
</a-form-item>
<a-form-item label="名称" name="name">
<a-input v-model:value="form.name" placeholder="SKU 名称/描述" />
</a-form-item>
<a-form-item label="条形码" name="barcode">
<a-input v-model:value="form.barcode" placeholder="可选" />
</a-form-item>
<a-form-item label="标签" name="label">
<a-input v-model:value="form.label" placeholder="可选别名" />
</a-form-item>
<a-form-item label="HS 编码" name="hs">
<a-input v-model:value="form.hs" placeholder="可选" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.filter-form :deep(.ant-form-item) {
margin-bottom: 12px;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
import { api } from '@/utils/request'
import type { ApiKeyRecord, ApiKeyCreateParams, ApiKeyCreateResult } from '@/types/api'
export const MAX_KEYS_PER_USER = 10
export const useApiKeyStore = defineStore('api-key', () => {
const keys = ref<ApiKeyRecord[]>([])
const loading = ref(false)
const canCreate = computed(() => keys.value.length < MAX_KEYS_PER_USER)
const keyCount = computed(() => keys.value.length)
async function fetchMyKeys() {
loading.value = true
try {
const data = await api.get<ApiKeyRecord[]>('/api/v1/me/api-keys')
keys.value = data
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '获取 API Key 列表失败'
message.error(msg)
} finally {
loading.value = false
}
}
async function createKey(params: ApiKeyCreateParams): Promise<ApiKeyCreateResult | null> {
try {
const data = await api.post<ApiKeyCreateResult>('/api/v1/me/api-keys', params)
await fetchMyKeys()
return data
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '创建 API Key 失败'
message.error(msg)
return null
}
}
async function toggleKey(id: number, enabled: boolean) {
try {
await api.patch(`/api/v1/me/api-keys/${id}/toggle`, { enabled })
await fetchMyKeys()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
}
}
async function deleteKey(id: number) {
try {
await api.delete(`/api/v1/me/api-keys/${id}`)
await fetchMyKeys()
message.success('已删除')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '删除失败'
message.error(msg)
}
}
return {
keys,
loading,
canCreate,
keyCount,
MAX_KEYS_PER_USER,
fetchMyKeys,
createKey,
toggleKey,
deleteKey,
}
})
+251
View File
@@ -0,0 +1,251 @@
import { api } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
// ─── Types ───
export interface SkuMappingRecord {
id: number
company_id: number
platform_id: number
store_id: number | null
origin_sku: string
origin_sku_id: number | null
platform_outer_sku: string | null
platform_product_id: string
enabled: boolean
note: string | null
created_at: string
updated_at: string
}
export interface SkuMappingFilters {
origin_sku: string
platform_outer_sku: string
enabled: boolean | undefined
}
export interface SkuMappingForm {
company_id: number | undefined
platform_id: number | undefined
store_id: number | undefined
origin_sku: string
origin_sku_id: number | undefined
platform_product_id: string
platform_outer_sku: string
generation_strategy: string
warehouse_id: number | undefined
enabled: boolean
note: string
}
export interface GenerateSkuParams {
origin_sku_id: number
platform_id: number
strategy: 'prefix' | 'prefix_random' | 'manual'
prefix: string
random_length: number
manual_value: string
}
export interface GenerateSkuResult {
generated_sku: string
strategy_record: string
has_duplicate: boolean
existing_mappings: Array<{
id: number
store_id: number | null
platform_outer_sku: string | null
note: string | null
}>
message: string
}
export interface DuplicateCheckResult {
has_duplicate: boolean
existing_mappings: Array<{
id: number
store_id: number | null
platform_outer_sku: string | null
note: string | null
}>
message: string
}
// ─── Store ───
export const useSkuMappingStore = defineStore('skuMapping', () => {
const items = ref<SkuMappingRecord[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
per_page: 15,
total: 0,
})
const cascadeValue = reactive({
company_id: undefined as number | undefined,
platform_id: undefined as number | undefined,
store_id: undefined as number | undefined,
})
const filters = reactive<SkuMappingFilters>({
origin_sku: '',
platform_outer_sku: '',
enabled: undefined,
})
// Lookups
const companies = ref<{ id: number; name: string; label: string | null }[]>([])
const platforms = ref<{ id: number; name: string; label: string | null }[]>([])
const stores = ref<
{ id: number; company_id: number; platform_id: number; name: string; label: string | null }[]
>([])
const companyMap = computed(
() =>
new Map(
companies.value.map((c) => [
c.id,
c.label && c.label !== 'null' ? c.label : c.name,
]),
),
)
const platformMap = computed(
() =>
new Map(
platforms.value.map((p) => [
p.id,
p.label && p.label !== 'null' ? p.label : p.name || `平台 #${p.id}`,
]),
),
)
const storeMap = computed(
() =>
new Map(
stores.value.map((s) => [
s.id,
s.label && s.label !== 'null' ? s.label : s.name,
]),
),
)
// SKU Origin lookups for select
const skuOrigins = ref<{ id: number; company_id: number; sku: string; name: string }[]>([])
async function loadLookups() {
try {
const [c, p, s] = await Promise.all([
api.get<typeof companies.value>('/api/v1/companies'),
api.get<typeof platforms.value>('/api/v1/platforms'),
api.get<typeof stores.value>('/api/v1/stores'),
])
companies.value = c
platforms.value = p
stores.value = s
} catch (err) {
console.warn('加载查找表数据失败', err)
}
}
async function loadSkuOrigins(company_id?: number) {
try {
const data = await api.get<PaginatedData<{ id: number; company_id: number; sku: string; name: string }>>('/api/v1/sku-origins', {
company_id,
per_page: 100,
})
skuOrigins.value = data.items
} catch (err) {
console.warn('加载 SKU Origin 列表失败', err)
}
}
async function fetchItems() {
loading.value = true
try {
const data = await api.get<PaginatedData<SkuMappingRecord>>('/api/v1/sku-mappings', {
page: pagination.page,
per_page: pagination.per_page,
company_id: cascadeValue.company_id,
platform_id: cascadeValue.platform_id,
store_id: cascadeValue.store_id,
origin_sku: filters.origin_sku || undefined,
platform_outer_sku: filters.platform_outer_sku || undefined,
enabled: filters.enabled,
})
items.value = data.items
pagination.total = data.total
pagination.page = data.page
} catch (err: unknown) {
items.value = []
pagination.total = 0
const msg = err instanceof Error ? err.message : '获取映射列表失败'
message.error(msg)
} finally {
loading.value = false
}
}
function resetFilters() {
filters.origin_sku = ''
filters.platform_outer_sku = ''
filters.enabled = undefined
cascadeValue.company_id = undefined
cascadeValue.platform_id = undefined
cascadeValue.store_id = undefined
pagination.page = 1
}
async function createItem(form: SkuMappingForm) {
await api.post('/api/v1/sku-mappings', form)
message.success('创建成功')
await fetchItems()
}
async function updateItem(id: number, form: Partial<SkuMappingForm>) {
await api.put(`/api/v1/sku-mappings/${id}`, form)
message.success('更新成功')
await fetchItems()
}
async function deleteItem(id: number) {
await api.delete(`/api/v1/sku-mappings/${id}`)
message.success('删除成功')
await fetchItems()
}
async function generateSku(params: GenerateSkuParams): Promise<GenerateSkuResult> {
return await api.post<GenerateSkuResult>('/api/v1/sku-mappings/generate-sku', params)
}
async function checkDuplicate(
origin_sku_id: number,
platform_id: number,
): Promise<DuplicateCheckResult> {
return await api.get<DuplicateCheckResult>('/api/v1/sku-mappings/check-duplicate', {
origin_sku_id,
platform_id,
})
}
return {
items,
loading,
pagination,
cascadeValue,
filters,
companies,
platforms,
stores,
companyMap,
platformMap,
storeMap,
skuOrigins,
loadLookups,
loadSkuOrigins,
fetchItems,
resetFilters,
createItem,
updateItem,
deleteItem,
generateSku,
checkDuplicate,
}
})
+149
View File
@@ -0,0 +1,149 @@
import { api } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
// ─── Types ───
export interface SkuOriginRecord {
id: number
company_id: number
sku: string
barcode: string
name: string
label: string | null
hs: string | null
created_at: string
updated_at: string
}
export interface SkuOriginDetail extends SkuOriginRecord {
ledger: string | null
ext: Record<string, unknown> | null
}
export interface SkuOriginFilters {
sku: string
name: string
barcode: string
}
export interface SkuOriginForm {
company_id: number | undefined
sku: string
barcode: string
name: string
label: string
hs: string
ledger: string
}
// ─── Store ───
export const useSkuOriginStore = defineStore('skuOrigin', () => {
const items = ref<SkuOriginRecord[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
per_page: 15,
total: 0,
})
const cascadeValue = reactive({
company_id: undefined as number | undefined,
platform_id: undefined as number | undefined,
store_id: undefined as number | undefined,
})
const filters = reactive<SkuOriginFilters>({
sku: '',
name: '',
barcode: '',
})
// Lookup maps
const companies = ref<{ id: number; name: string; label: string | null }[]>([])
const companyMap = computed(
() =>
new Map(
companies.value.map((c) => [
c.id,
c.label && c.label !== 'null' ? c.label : c.name,
]),
),
)
async function loadLookups() {
try {
companies.value = await api.get<{ id: number; name: string; label: string | null }[]>(
'/api/v1/companies',
)
} catch (err) {
console.warn('加载查找表数据失败', err)
}
}
async function fetchItems() {
loading.value = true
try {
const data = await api.get<PaginatedData<SkuOriginRecord>>('/api/v1/sku-origins', {
page: pagination.page,
per_page: pagination.per_page,
company_id: cascadeValue.company_id,
sku: filters.sku || undefined,
name: filters.name || undefined,
barcode: filters.barcode || undefined,
})
items.value = data.items
pagination.total = data.total
pagination.page = data.page
} catch (err: unknown) {
items.value = []
pagination.total = 0
const msg = err instanceof Error ? err.message : '获取 SKU 列表失败'
message.error(msg)
} finally {
loading.value = false
}
}
function resetFilters() {
filters.sku = ''
filters.name = ''
filters.barcode = ''
cascadeValue.company_id = undefined
cascadeValue.platform_id = undefined
cascadeValue.store_id = undefined
pagination.page = 1
}
async function createItem(form: SkuOriginForm) {
await api.post('/api/v1/sku-origins', form)
message.success('创建成功')
await fetchItems()
}
async function updateItem(id: number, form: Partial<SkuOriginForm>) {
await api.put(`/api/v1/sku-origins/${id}`, form)
message.success('更新成功')
await fetchItems()
}
async function deleteItem(id: number) {
await api.delete(`/api/v1/sku-origins/${id}`)
message.success('删除成功')
await fetchItems()
}
return {
items,
loading,
pagination,
cascadeValue,
filters,
companies,
companyMap,
loadLookups,
fetchItems,
resetFilters,
createItem,
updateItem,
deleteItem,
}
})
+1
View File
@@ -7,6 +7,7 @@ export interface UserInfo {
email: string
role: string
status: number
api_key_enabled?: boolean
ext?: Record<string, unknown> | null
}
+24
View File
@@ -304,6 +304,30 @@ export interface OperationLogFilters {
created_at_range: [string, string] | null
}
/** ─── API Key ─── */
export interface ApiKeyRecord {
id: number
user_id: number
name: string
key_prefix: string
last_used_at: string | null
expires_at: string | null
enabled: boolean
created_at: string
user?: { id: number; username: string; api_key_enabled?: boolean }
}
export interface ApiKeyCreateParams {
name: string
expires_at?: string | null
}
export interface ApiKeyCreateResult {
plain_key: string
api_key: ApiKeyRecord
}
/** 业务异常 */
export class ApiError extends Error {
code: number