feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
@@ -1,246 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from '@/types'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<CategoryTree[]>([])
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = await categoriesApi.getCategoryTree()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(tree.value)
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
parentCategoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openEditDialog(c: Category) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: c.id,
|
||||
categoryName: c.categoryName,
|
||||
categoryIcon: c.categoryIcon || '',
|
||||
parentCategoryId: c.parentCategoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCategoryRequest = {
|
||||
categoryName: dialogForm.categoryName,
|
||||
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await categoriesApi.createCategory(payload)
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||
ElMessage.success('分类更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Category) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await categoriesApi.deleteCategory(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(fetchTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增分类</span>
|
||||
</el-button>
|
||||
<el-button @click="fetchTree">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tree"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||
<el-table-column label="图标" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<el-image
|
||||
v-if="row.categoryIcon"
|
||||
:src="row.categoryIcon"
|
||||
:preview-src-list="[row.categoryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="父级">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row.parent?.categoryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="子级数" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无分类" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="dialogRef"
|
||||
:model="dialogForm"
|
||||
:rules="dialogRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="名称" prop="categoryName">
|
||||
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.parentCategoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="顶级(可选)"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from '@/types'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<CategoryTree[]>([])
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = await categoriesApi.getCategoryTree()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(tree.value)
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
parentCategoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openEditDialog(c: Category) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: c.id,
|
||||
categoryName: c.categoryName,
|
||||
categoryIcon: c.categoryIcon || '',
|
||||
parentCategoryId: c.parentCategoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCategoryRequest = {
|
||||
categoryName: dialogForm.categoryName,
|
||||
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await categoriesApi.createCategory(payload)
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||
ElMessage.success('分类更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Category) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await categoriesApi.deleteCategory(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(fetchTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增分类</span>
|
||||
</el-button>
|
||||
<el-button @click="fetchTree">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tree"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||
<el-table-column label="图标" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<el-image
|
||||
v-if="row.categoryIcon"
|
||||
:src="row.categoryIcon"
|
||||
:preview-src-list="[row.categoryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="父级">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row.parent?.categoryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="子级数" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无分类" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="dialogRef"
|
||||
:model="dialogForm"
|
||||
:rules="dialogRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="名称" prop="categoryName">
|
||||
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.parentCategoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="顶级(可选)"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,231 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
} from '@/types'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Country[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<CountryFilter>>({
|
||||
countryName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await countriesApi.getCountriesList(filter)
|
||||
const data = await countriesApi.getCountriesList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||
countryName: '',
|
||||
countryIcon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(c: Country) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCountryRequest = {
|
||||
countryName: dialogForm.countryName,
|
||||
countryIcon: dialogForm.countryIcon || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await countriesApi.createCountry(payload)
|
||||
ElMessage.success('国家创建成功')
|
||||
} else {
|
||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||
ElMessage.success('国家更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Country) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await countriesApi.deleteCountry(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.countryName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增国家</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<el-image
|
||||
v-if="row.countryIcon"
|
||||
:src="row.countryIcon"
|
||||
:preview-src-list="[row.countryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="countryName" label="名称" min-width="200" />
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
{{ new Date(row.createdAt).toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无国家" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="countryName">
|
||||
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
} from '@/types'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Country[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<CountryFilter>>({
|
||||
countryName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await countriesApi.getCountriesList(filter)
|
||||
const data = await countriesApi.getCountriesList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||
countryName: '',
|
||||
countryIcon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(c: Country) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCountryRequest = {
|
||||
countryName: dialogForm.countryName,
|
||||
countryIcon: dialogForm.countryIcon || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await countriesApi.createCountry(payload)
|
||||
ElMessage.success('国家创建成功')
|
||||
} else {
|
||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||
ElMessage.success('国家更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Country) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await countriesApi.deleteCountry(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.countryName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增国家</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<el-image
|
||||
v-if="row.countryIcon"
|
||||
:src="row.countryIcon"
|
||||
:preview-src-list="[row.countryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="countryName" label="名称" min-width="200" />
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
{{ new Date(row.createdAt).toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无国家" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="countryName">
|
||||
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
+1985
-1985
File diff suppressed because it is too large
Load Diff
@@ -1,181 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login({ username: form.username, password: form.password })
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error toast is shown by axios response interceptor
|
||||
console.error('Login failed', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg" />
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="brand-logo">Inkreach</div>
|
||||
<div class="brand-subtitle">官网后台</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 40px 36px 28px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-color);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login({ username: form.username, password: form.password })
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error toast is shown by axios response interceptor
|
||||
console.error('Login failed', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg" />
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="brand-logo">Inkreach</div>
|
||||
<div class="brand-subtitle">官网后台</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 40px 36px 28px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-color);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,317 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
Country,
|
||||
Category,
|
||||
CategoryTree,
|
||||
} from '@/types'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Position[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const countries = ref<Country[]>([])
|
||||
const categoriesTree = ref<CategoryTree[]>([])
|
||||
|
||||
const filter = reactive<Required<PositionFilter>>({
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function loadLookups() {
|
||||
const [c, ct] = await Promise.all([
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
])
|
||||
countries.value = c.items
|
||||
categoriesTree.value = ct
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await positionsApi.getPositionsList(filter)
|
||||
const data = await positionsApi.getPositionsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryId = ''
|
||||
filter.categoryId = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||
indexVal: 0,
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(p: Position) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: p.id,
|
||||
indexVal: p.indexVal,
|
||||
countryId: p.countryId || '',
|
||||
categoryId: p.categoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreatePositionRequest = {
|
||||
indexVal: Number(dialogForm.indexVal),
|
||||
countryId: dialogForm.countryId || undefined,
|
||||
categoryId: dialogForm.categoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await positionsApi.createPosition(payload)
|
||||
ElMessage.success('位置创建成功')
|
||||
} else {
|
||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||
ElMessage.success('位置更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(p: Position) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await positionsApi.deletePosition(p.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function getCategoryName(p: Position): string {
|
||||
if (!p.category) return p.categoryId || '-'
|
||||
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLookups()
|
||||
await fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="请选择国家"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-cascader
|
||||
v-model="filter.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||
placeholder="请选择分类"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增位置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||
<el-table-column label="国家" min-width="160">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ row.country?.countryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" min-width="200">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无位置" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="排序值" prop="indexVal">
|
||||
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="可选"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
Country,
|
||||
Category,
|
||||
CategoryTree,
|
||||
} from '@/types'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Position[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const countries = ref<Country[]>([])
|
||||
const categoriesTree = ref<CategoryTree[]>([])
|
||||
|
||||
const filter = reactive<Required<PositionFilter>>({
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function loadLookups() {
|
||||
const [c, ct] = await Promise.all([
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
])
|
||||
countries.value = c.items
|
||||
categoriesTree.value = ct
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await positionsApi.getPositionsList(filter)
|
||||
const data = await positionsApi.getPositionsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryId = ''
|
||||
filter.categoryId = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||
indexVal: 0,
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(p: Position) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: p.id,
|
||||
indexVal: p.indexVal,
|
||||
countryId: p.countryId || '',
|
||||
categoryId: p.categoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreatePositionRequest = {
|
||||
indexVal: Number(dialogForm.indexVal),
|
||||
countryId: dialogForm.countryId || undefined,
|
||||
categoryId: dialogForm.categoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await positionsApi.createPosition(payload)
|
||||
ElMessage.success('位置创建成功')
|
||||
} else {
|
||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||
ElMessage.success('位置更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(p: Position) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await positionsApi.deletePosition(p.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function getCategoryName(p: Position): string {
|
||||
if (!p.category) return p.categoryId || '-'
|
||||
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLookups()
|
||||
await fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="请选择国家"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-cascader
|
||||
v-model="filter.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||
placeholder="请选择分类"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增位置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||
<el-table-column label="国家" min-width="160">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ row.country?.countryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" min-width="200">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无位置" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="排序值" prop="indexVal">
|
||||
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="可选"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Goods, Refresh } from '@element-plus/icons-vue'
|
||||
import GoodsView from '@/views/goods/GoodsView.vue'
|
||||
import SyncView from '@/views/sync/SyncView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
||||
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
||||
]
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'goods')
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
router.replace({ query: { ...route.query, tab: val } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pm-wrapper">
|
||||
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
||||
<el-tab-pane
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
:name="t.name"
|
||||
>
|
||||
<template #label>
|
||||
<span class="pm-tab-label">
|
||||
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
||||
{{ t.label }}
|
||||
</span>
|
||||
</template>
|
||||
<component :is="t.comp" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
||||
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
||||
.pm-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pm-tab-icon { font-size: 14px; }
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Goods, Refresh } from '@element-plus/icons-vue'
|
||||
import GoodsView from '@/views/goods/GoodsView.vue'
|
||||
import SyncView from '@/views/sync/SyncView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
||||
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
||||
]
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'goods')
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
router.replace({ query: { ...route.query, tab: val } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pm-wrapper">
|
||||
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
||||
<el-tab-pane
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
:name="t.name"
|
||||
>
|
||||
<template #label>
|
||||
<span class="pm-tab-label">
|
||||
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
||||
{{ t.label }}
|
||||
</span>
|
||||
</template>
|
||||
<component :is="t.comp" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
||||
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
||||
.pm-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pm-tab-icon { font-size: 14px; }
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||
import type { SyncLog } from '@/types'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const logs = ref<SyncLog[]>([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||
import type { SyncLog } from '@/types'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const logs = ref<SyncLog[]>([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
|
||||
const currentType = ref<SyncType>('PRODUCTS')
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(50) as any
|
||||
logs.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync status', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(50) as any
|
||||
logs.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync status', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncTypeLabel(type: SyncType): string {
|
||||
if (type === 'CATEGORIES') return '分类'
|
||||
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
|
||||
@@ -33,33 +33,33 @@ function syncTypeLabel(type: SyncType): string {
|
||||
}
|
||||
|
||||
async function pollUntilDone(type: SyncType) {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(5) as any
|
||||
const latest = Array.isArray(data) ? data : []
|
||||
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
||||
const top = latest.find((l: SyncLog) => l.type === type)
|
||||
if (top && top.status !== 'RUNNING') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
syncing.value = false
|
||||
if (top.status === 'SUCCESS') {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(5) as any
|
||||
const latest = Array.isArray(data) ? data : []
|
||||
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
||||
const top = latest.find((l: SyncLog) => l.type === type)
|
||||
if (top && top.status !== 'RUNNING') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
syncing.value = false
|
||||
if (top.status === 'SUCCESS') {
|
||||
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
|
||||
} else {
|
||||
} else {
|
||||
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
|
||||
}
|
||||
await refreshLogs()
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function handleSyncProducts() {
|
||||
await doSync('PRODUCTS')
|
||||
}
|
||||
|
||||
}
|
||||
await refreshLogs()
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function handleSyncProducts() {
|
||||
await doSync('PRODUCTS')
|
||||
}
|
||||
|
||||
async function handleSyncCategories() {
|
||||
await doSync('CATEGORIES')
|
||||
await doSync('CATEGORIES')
|
||||
}
|
||||
|
||||
async function handleSyncProductDetails() {
|
||||
@@ -68,94 +68,94 @@ async function handleSyncProductDetails() {
|
||||
|
||||
async function doSync(type: SyncType) {
|
||||
const label = syncTypeLabel(type)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||
'确认',
|
||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
syncing.value = true
|
||||
currentType.value = type
|
||||
try {
|
||||
'确认',
|
||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
syncing.value = true
|
||||
currentType.value = type
|
||||
try {
|
||||
if (type === 'PRODUCTS') {
|
||||
await syncApi.syncProducts()
|
||||
} else if (type === 'PRODUCT_DETAILS') {
|
||||
await syncApi.syncProductDetails()
|
||||
} else {
|
||||
await syncApi.syncCategories()
|
||||
}
|
||||
ElMessage.info(`${label}同步已开始`)
|
||||
pollUntilDone(type)
|
||||
} catch {
|
||||
ElMessage.error(`${label}同步启动失败`)
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s?: string): string {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDuration(start?: string, end?: string): string | null {
|
||||
if (!start || !end) return null
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
} else {
|
||||
await syncApi.syncCategories()
|
||||
}
|
||||
ElMessage.info(`${label}同步已开始`)
|
||||
pollUntilDone(type)
|
||||
} catch {
|
||||
ElMessage.error(`${label}同步启动失败`)
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s?: string): string {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDuration(start?: string, end?: string): string | null {
|
||||
if (!start || !end) return null
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = logs.value.length
|
||||
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
||||
const failed = logs.value.filter(l => l.status === 'FAILED').length
|
||||
const lastLog = logs.value[0]
|
||||
return { total, success, failed, lastLog }
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshLogs()
|
||||
timer = setInterval(refreshLogs, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-page" v-loading="loading">
|
||||
<!-- Action Card -->
|
||||
<div class="sync-action-card">
|
||||
<div class="sync-action-info">
|
||||
<div class="sync-action-icon">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="sync-action-title">数据同步</h2>
|
||||
return { total, success, failed, lastLog }
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshLogs()
|
||||
timer = setInterval(refreshLogs, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-page" v-loading="loading">
|
||||
<!-- Action Card -->
|
||||
<div class="sync-action-card">
|
||||
<div class="sync-action-info">
|
||||
<div class="sync-action-icon">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="sync-action-title">数据同步</h2>
|
||||
<p class="sync-action-desc">分类和产品每小时自动同步;全部 SDS 原产品详情每天 03:30 自动同步,也可手动执行</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-action-buttons">
|
||||
<el-button
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'CATEGORIES'"
|
||||
:disabled="syncing"
|
||||
@click="handleSyncCategories"
|
||||
>
|
||||
同步分类
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-action-buttons">
|
||||
<el-button
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'CATEGORIES'"
|
||||
:disabled="syncing"
|
||||
@click="handleSyncCategories"
|
||||
>
|
||||
同步分类
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'PRODUCTS'"
|
||||
:disabled="syncing"
|
||||
:icon="Refresh"
|
||||
@click="handleSyncProducts"
|
||||
>
|
||||
:disabled="syncing"
|
||||
:icon="Refresh"
|
||||
@click="handleSyncProducts"
|
||||
>
|
||||
同步产品
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -168,307 +168,307 @@ onUnmounted(() => {
|
||||
>
|
||||
同步全部原产品详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="sync-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.total }}</span>
|
||||
<span class="stat-label">总同步次数</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-success">{{ stats.success }}</span>
|
||||
<span class="stat-label">成功</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="sync-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.total }}</span>
|
||||
<span class="stat-label">总同步次数</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-success">{{ stats.success }}</span>
|
||||
<span class="stat-label">成功</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
|
||||
<span class="stat-label">最近同步</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Timeline -->
|
||||
<div class="sync-logs">
|
||||
<div class="sync-logs-head">
|
||||
<h3 class="sync-logs-title">同步日志</h3>
|
||||
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
||||
<el-empty description="暂无同步记录" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<div v-else class="sync-timeline">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="timeline-item"
|
||||
>
|
||||
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
<el-icon :size="12">
|
||||
<Loading v-if="log.status === 'RUNNING'" />
|
||||
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="stat-label">最近同步</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Timeline -->
|
||||
<div class="sync-logs">
|
||||
<div class="sync-logs-head">
|
||||
<h3 class="sync-logs-title">同步日志</h3>
|
||||
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
||||
<el-empty description="暂无同步记录" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<div v-else class="sync-timeline">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="timeline-item"
|
||||
>
|
||||
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
<el-icon :size="12">
|
||||
<Loading v-if="log.status === 'RUNNING'" />
|
||||
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="timeline-type">{{ syncTypeLabel(log.type) }}</span>
|
||||
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||
</span>
|
||||
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||
</span>
|
||||
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
|
||||
<el-icon :size="11"><Clock /></el-icon>
|
||||
<el-icon :size="11"><Clock /></el-icon>
|
||||
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
||||
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Action Card */
|
||||
.sync-action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-action-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sync-action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--brand-color, #ff6800);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-action-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-action-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.sync-action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.sync-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 16px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-value.stat-success { color: #67c23a; }
|
||||
.stat-value.stat-failed { color: #f56c6c; }
|
||||
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: #ebeef5;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.sync-logs {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sync-logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.sync-logs-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-empty {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.sync-timeline {
|
||||
padding: 16px 20px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.timeline-dot.is-success {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.timeline-dot.is-failed {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.timeline-dot.is-running {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-status.is-success { color: #67c23a; }
|
||||
.timeline-status.is-failed { color: #f56c6c; }
|
||||
.timeline-status.is-running { color: #409eff; }
|
||||
|
||||
.timeline-type {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timeline-message {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Action Card */
|
||||
.sync-action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-action-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sync-action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--brand-color, #ff6800);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-action-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-action-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.sync-action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.sync-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 16px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-value.stat-success { color: #67c23a; }
|
||||
.stat-value.stat-failed { color: #f56c6c; }
|
||||
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: #ebeef5;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.sync-logs {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sync-logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.sync-logs-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-empty {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.sync-timeline {
|
||||
padding: 16px 20px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.timeline-dot.is-success {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.timeline-dot.is-failed {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.timeline-dot.is-running {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-status.is-success { color: #67c23a; }
|
||||
.timeline-status.is-failed { color: #f56c6c; }
|
||||
.timeline-status.is-running { color: #409eff; }
|
||||
|
||||
.timeline-type {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timeline-message {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,324 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Tag,
|
||||
TagGroup,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
} from '@/types'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
const allGroups = ref<TagGroup[]>([])
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
tagName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tagsApi.getTagsList(filter)
|
||||
const data = await tagsApi.getTagsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.tagName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||
id: undefined,
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
tagFontColor: '#ffffff',
|
||||
timing: '',
|
||||
tagGroupId: null,
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(t: Tag) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: t.id,
|
||||
tagName: t.tagName,
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
tagFontColor: t.tagFontColor || '#ffffff',
|
||||
timing: t.timing || '',
|
||||
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateTagRequest = {
|
||||
tagName: dialogForm.tagName,
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
ElMessage.success('标签创建成功')
|
||||
} else {
|
||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||
ElMessage.success('标签更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(t: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await tagsApi.deleteTag(t.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.tagName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增标签</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="预览" width="120">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span
|
||||
v-if="row.tagColor"
|
||||
class="tag-preview"
|
||||
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
||||
>{{ row.tagName }}</span>
|
||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||
<el-table-column label="背景色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字体色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属分组" min-width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||
<span v-else style="color: #999;">未分组</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无标签" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="tagName">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="背景色">
|
||||
<el-color-picker v-model="dialogForm.tagColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="字体色">
|
||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属分组">
|
||||
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
||||
<el-option
|
||||
v-for="g in allGroups"
|
||||
:key="g.id"
|
||||
:label="g.groupName"
|
||||
:value="Number(g.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="定时">
|
||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.color-readout {
|
||||
margin-left: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tag-preview {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Tag,
|
||||
TagGroup,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
} from '@/types'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
const allGroups = ref<TagGroup[]>([])
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
tagName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tagsApi.getTagsList(filter)
|
||||
const data = await tagsApi.getTagsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.tagName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||
id: undefined,
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
tagFontColor: '#ffffff',
|
||||
timing: '',
|
||||
tagGroupId: null,
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(t: Tag) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: t.id,
|
||||
tagName: t.tagName,
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
tagFontColor: t.tagFontColor || '#ffffff',
|
||||
timing: t.timing || '',
|
||||
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateTagRequest = {
|
||||
tagName: dialogForm.tagName,
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
ElMessage.success('标签创建成功')
|
||||
} else {
|
||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||
ElMessage.success('标签更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(t: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await tagsApi.deleteTag(t.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.tagName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增标签</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="预览" width="120">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span
|
||||
v-if="row.tagColor"
|
||||
class="tag-preview"
|
||||
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
||||
>{{ row.tagName }}</span>
|
||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||
<el-table-column label="背景色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字体色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属分组" min-width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||
<span v-else style="color: #999;">未分组</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无标签" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="tagName">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="背景色">
|
||||
<el-color-picker v-model="dialogForm.tagColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="字体色">
|
||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属分组">
|
||||
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
||||
<el-option
|
||||
v-for="g in allGroups"
|
||||
:key="g.id"
|
||||
:label="g.groupName"
|
||||
:value="Number(g.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="定时">
|
||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.color-readout {
|
||||
margin-left: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tag-preview {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user