add sku mapping
This commit is contained in:
@@ -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': '队列监控',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -7,6 +7,7 @@ export interface UserInfo {
|
||||
email: string
|
||||
role: string
|
||||
status: number
|
||||
api_key_enabled?: boolean
|
||||
ext?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user