601 lines
14 KiB
Plaintext
601 lines
14 KiB
Plaintext
<template>
|
||
<view class="admin-main">
|
||
<view class="label-layout">
|
||
<!-- 左侧标签组 -->
|
||
<view class="label-group-aside">
|
||
<view class="aside-header" @click="openGroupModal()">
|
||
<text class="btn-group-add">+ 添加分组</text>
|
||
</view>
|
||
<scroll-view class="aside-list" :scroll-y="true">
|
||
<view
|
||
v-for="(group, gIndex) in groups"
|
||
:key="gIndex"
|
||
class="group-item"
|
||
:class="activeGroupIndex === gIndex ? 'active' : ''"
|
||
@click="activeGroupIndex = gIndex"
|
||
>
|
||
<view class="group-left">
|
||
<image src="/static/logo.png" class="folder-icon" />
|
||
<text class="group-name">{{ group.name }}</text>
|
||
</view>
|
||
<view class="group-ops">
|
||
<text class="op-more">...</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- 右侧标签列表 -->
|
||
<view class="label-content-main">
|
||
<view class="table-card-full">
|
||
<view class="table-toolbar">
|
||
<button class="btn-add-label" @click="openLabelDrawer()">添加标签</button>
|
||
</view>
|
||
|
||
<view class="table-header-row">
|
||
<text class="th-cell flex-1">ID</text>
|
||
<text class="th-cell flex-3">标签名称</text>
|
||
<text class="th-cell flex-3">分类名称</text>
|
||
<text class="th-cell flex-2 text-center">状态</text>
|
||
<text class="th-cell flex-2 text-center">移动端展示</text>
|
||
<text class="th-cell flex-2 text-center">操作</text>
|
||
</view>
|
||
|
||
<view class="table-body-scroll">
|
||
<view v-if="filteredLabels.length === 0" class="empty-box">
|
||
<text class="empty-text">该分组下暂无标签</text>
|
||
</view>
|
||
<view v-for="(label, lIndex) in filteredLabels" :key="lIndex" class="table-row-line">
|
||
<text class="td-cell flex-1 color-9">{{ label.id }}</text>
|
||
<view class="td-cell flex-3">
|
||
<text class="label-tag-box">{{ label.name }}</text>
|
||
</view>
|
||
<text class="td-cell flex-3">{{ groups[activeGroupIndex]?.name }}</text>
|
||
<view class="td-cell flex-2 row-center">
|
||
<view class="status-switch-mini" :class="label.status ? 'active' : ''" @click="label.status = !label.status">
|
||
<text class="switch-txt-inner">{{ label.status ? '开启' : '关闭' }}</text>
|
||
<view class="switch-dot-mini"></view>
|
||
</view>
|
||
</view>
|
||
<view class="td-cell flex-2 row-center">
|
||
<view class="status-switch-mini" :class="label.showInMobile ? 'active' : ''" @click="label.showInMobile = !label.showInMobile">
|
||
<text class="switch-txt-inner">{{ label.showInMobile ? '开启' : '关闭' }}</text>
|
||
<view class="switch-dot-mini"></view>
|
||
</view>
|
||
</view>
|
||
<view class="td-cell flex-2 row-center">
|
||
<text class="btn-op-blue" @click="openLabelDrawer(label)">修改</text>
|
||
<view class="v-line"></view>
|
||
<text class="btn-op-red" @click="deleteLabel(label)">删除</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 分页模拟 -->
|
||
<view class="table-pagination">
|
||
<text class="page-total">共 {{ filteredLabels.length }} 条</text>
|
||
<view class="page-size-selector">
|
||
<text>15条/页</text>
|
||
<text class="arrow-down">v</text>
|
||
</view>
|
||
<view class="page-numbers">
|
||
<text class="page-btn active">1</text>
|
||
</view>
|
||
<view class="page-jump">
|
||
<text>前往</text>
|
||
<input class="jump-input" :value="'1'" />
|
||
<text>页</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 标签抽屉 (右侧展示) -->
|
||
<view class="drawer-mask" v-if="showDrawerMask" @click="closeLabelDrawer">
|
||
<view class="drawer-content" @click.stop="" :class="{ 'drawer-show': showDrawer }">
|
||
<view class="drawer-header">
|
||
<text class="drawer-title">添加标签</text>
|
||
<text class="drawer-close" @click="closeLabelDrawer"></text>
|
||
</view>
|
||
<view class="drawer-body">
|
||
<view class="form-item">
|
||
<text class="form-label">标签名称:</text>
|
||
<input class="drawer-input" v-model="labelForm.name" placeholder="请输入标签名称" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">所属分组:</text>
|
||
<text class="form-value">{{ groups[activeGroupIndex]?.name }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="drawer-footer">
|
||
<button class="btn-footer-cancel" @click="closeLabelDrawer">取消</button>
|
||
<button class="btn-footer-submit" @click="closeLabelDrawer">确定</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||
import {
|
||
fetchLabelGroups, saveLabelGroup, deleteLabelGroup,
|
||
fetchLabels, saveLabel, deleteLabel,
|
||
ProductLabelGroup, ProductLabel
|
||
} from '@/services/admin/productLabelService.uts'
|
||
|
||
const groups = ref<ProductLabelGroup[]>([])
|
||
const labels = ref<ProductLabel[]>([])
|
||
const isLoading = ref(false)
|
||
const activeGroupIndex = ref(0)
|
||
|
||
const activeGroupId = computed((): string | null => {
|
||
if (groups.value.length === 0) return null
|
||
return groups.value[activeGroupIndex.value]?.id ?? null
|
||
})
|
||
|
||
const filteredLabels = computed((): ProductLabel[] => {
|
||
// 方案 B:全部是真实分组,所以直接根据 activeGroupId 过滤
|
||
// 如果当前选中的是“全部”组,则展示该组下的标签(或根据业务逻辑展示所有)
|
||
const gid = activeGroupId.value
|
||
if (gid == null) return [] as ProductLabel[]
|
||
|
||
// 如果“全部”组的名称确实是“全部”,且业务逻辑是展示所有标签:
|
||
if (groups.value[activeGroupIndex.value]?.name === '全部') {
|
||
return labels.value
|
||
}
|
||
|
||
return labels.value.filter((l: ProductLabel): boolean => l.group_id === gid)
|
||
})
|
||
|
||
onMounted(() => {
|
||
initData()
|
||
})
|
||
|
||
async function initData() {
|
||
isLoading.value = true
|
||
try {
|
||
// 1. 加载分组
|
||
const groupRes = await fetchLabelGroups()
|
||
groups.value = groupRes
|
||
|
||
// 如果没有任何分组(包括“全部”),则尝试初始化一个
|
||
if (groups.value.length === 0) {
|
||
await saveLabelGroup({ name: '全部', sort_order: 0 } as ProductLabelGroup)
|
||
const retryGroups = await fetchLabelGroups()
|
||
groups.value = retryGroups
|
||
}
|
||
|
||
// 2. 加载所有标签
|
||
const labelRes = await fetchLabels()
|
||
labels.value = labelRes
|
||
} catch (e) {
|
||
uni.showToast({ title: '初始化失败', icon: 'none' })
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 刷新标签列表
|
||
async function refreshLabels() {
|
||
const res = await fetchLabels()
|
||
labels.value = res
|
||
}
|
||
|
||
// Drawer logic
|
||
const showDrawerMask = ref(false)
|
||
const showDrawer = ref(false)
|
||
const isEditLabel = ref(false)
|
||
const editingLabelId = ref<string | null>(null)
|
||
const labelForm = reactive({ name: '' })
|
||
|
||
function openLabelDrawer(label: ProductLabel | null = null) {
|
||
if (label != null) {
|
||
isEditLabel.value = true
|
||
editingLabelId.value = label.id ?? null
|
||
labelForm.name = label.name
|
||
} else {
|
||
isEditLabel.value = false
|
||
editingLabelId.value = null
|
||
labelForm.name = ''
|
||
}
|
||
showDrawerMask.value = true
|
||
setTimeout(() => {
|
||
showDrawer.value = true
|
||
}, 50)
|
||
}
|
||
|
||
function closeLabelDrawer() {
|
||
showDrawer.value = false
|
||
setTimeout(() => {
|
||
showDrawerMask.value = false
|
||
}, 300)
|
||
}
|
||
|
||
async function handleSaveLabel() {
|
||
if (!labelForm.name) {
|
||
uni.showToast({ title: '请输入名称', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const gid = activeGroupId.value
|
||
if (gid == null) return
|
||
|
||
const labelData: ProductLabel = {
|
||
id: editingLabelId.value ?? undefined,
|
||
name: labelForm.name,
|
||
group_id: gid,
|
||
is_active: true,
|
||
show_in_mobile: true,
|
||
sort_order: 0
|
||
}
|
||
|
||
const success = await saveLabel(labelData)
|
||
if (success) {
|
||
uni.showToast({ title: '保存成功' })
|
||
closeLabelDrawer()
|
||
refreshLabels()
|
||
}
|
||
}
|
||
|
||
async function onToggleStatus(label: ProductLabel) {
|
||
const updated = { ...label, is_active: !label.is_active } as ProductLabel
|
||
await saveLabel(updated)
|
||
refreshLabels()
|
||
}
|
||
|
||
async function onToggleMobile(label: ProductLabel) {
|
||
const updated = { ...label, show_in_mobile: !label.show_in_mobile } as ProductLabel
|
||
await saveLabel(updated)
|
||
refreshLabels()
|
||
}
|
||
|
||
async function deleteLabelItem(label: ProductLabel) {
|
||
if (label.id == null) return
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: '确定删除该标签吗?',
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
const success = await deleteLabel(label.id!)
|
||
if (success) {
|
||
uni.showToast({ title: '删除成功' })
|
||
refreshLabels()
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
function openGroupModal() {
|
||
uni.showModal({
|
||
title: '添加分组',
|
||
editable: true,
|
||
placeholderText: '请输入分组名称',
|
||
success: async (res) => {
|
||
if (res.confirm && res.content) {
|
||
await saveLabelGroup({ name: res.content, sort_order: groups.value.length } as ProductLabelGroup)
|
||
const groupRes = await fetchLabelGroups()
|
||
groups.value = groupRes
|
||
}
|
||
}
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.admin-main {
|
||
padding: 20px;
|
||
background-color: #f0f2f5;
|
||
height: 100vh;
|
||
}
|
||
|
||
.label-layout {
|
||
display: flex;
|
||
flex-direction: row;
|
||
height: 100%;
|
||
gap: 20px;
|
||
}
|
||
|
||
/* 左侧 */
|
||
.label-group-aside {
|
||
width: 200px;
|
||
background-color: #fff;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.aside-header {
|
||
padding: 16px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn-group-add { font-size: 14px; color: #999; }
|
||
|
||
.aside-list { flex: 1; }
|
||
|
||
.group-item {
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
border-left: 3px solid transparent;
|
||
}
|
||
|
||
.group-item.active {
|
||
background-color: #e6f7ff;
|
||
color: #1890ff;
|
||
border-left-color: #1890ff;
|
||
}
|
||
|
||
.group-left {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.folder-icon {
|
||
width: 16px;
|
||
height: 16px;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.group-name { font-size: 14px; }
|
||
.op-more { color: #ccc; }
|
||
|
||
/* 右侧 */
|
||
.label-content-main {
|
||
flex: 1;
|
||
background-color: #fff;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.table-card-full {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
padding: 20px;
|
||
}
|
||
|
||
.table-toolbar { margin-bottom: 20px; }
|
||
|
||
.btn-add-label {
|
||
background-color: #1890ff;
|
||
color: #fff;
|
||
height: 32px;
|
||
line-height: 32px;
|
||
padding: 0 16px;
|
||
font-size: 14px;
|
||
border-radius: 4px;
|
||
border: none;
|
||
margin: 0;
|
||
}
|
||
|
||
.table-header-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
background-color: #fafafa;
|
||
height: 44px;
|
||
align-items: center;
|
||
border: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.th-cell { font-size: 14px; font-weight: bold; padding: 0 12px; }
|
||
|
||
.table-body-scroll { flex: 1; overflow-y: auto; }
|
||
|
||
.table-row-line {
|
||
display: flex;
|
||
flex-direction: row;
|
||
min-height: 54px;
|
||
align-items: center;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
border-left: 1px solid #f0f0f0;
|
||
border-right: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.td-cell { padding: 0 12px; font-size: 14px; color: #666; }
|
||
.color-9 { color: #999; }
|
||
|
||
.label-tag-box {
|
||
background-color: #f0f9eb;
|
||
color: #67c23a;
|
||
padding: 4px 10px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.status-switch-mini {
|
||
width: 60px;
|
||
height: 24px;
|
||
background-color: #ccc;
|
||
border-radius: 12px;
|
||
position: relative;
|
||
transition: all 0.3s;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.status-switch-mini.active { background-color: #1890ff; }
|
||
|
||
.switch-txt-inner {
|
||
font-size: 11px;
|
||
color: #fff;
|
||
margin-left: 24px;
|
||
}
|
||
|
||
.status-switch-mini.active .switch-txt-inner {
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.switch-dot-mini {
|
||
width: 18px;
|
||
height: 18px;
|
||
background-color: #fff;
|
||
border-radius: 50%;
|
||
position: absolute;
|
||
left: 3px;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.status-switch-mini.active .switch-dot-mini { left: 39px; }
|
||
|
||
.btn-op-blue { color: #1890ff; font-size: 14px; cursor: pointer; }
|
||
.btn-op-red { color: #ff4d4f; font-size: 14px; cursor: pointer; }
|
||
.v-line { width: 1px; height: 12px; background-color: #eee; margin: 0 10px; }
|
||
|
||
/* 分页 */
|
||
.table-pagination {
|
||
padding-top: 20px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.page-total { font-size: 13px; color: #666; margin-right: 12px; }
|
||
|
||
.page-size-selector {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
border: 1px solid #dcdfe6;
|
||
padding: 0 8px;
|
||
height: 28px;
|
||
border-radius: 4px;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.page-size-selector text { font-size: 12px; }
|
||
.arrow-down { margin-left: 5px; color: #999; }
|
||
|
||
.page-numbers { display: flex; flex-direction: row; margin-right: 12px; }
|
||
|
||
.page-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
line-height: 28px;
|
||
text-align: center;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
margin: 0 2px;
|
||
}
|
||
|
||
.page-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
|
||
|
||
.page-jump {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
font-size: 13px;
|
||
color: #666;
|
||
}
|
||
|
||
.jump-input {
|
||
width: 40px;
|
||
height: 28px;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 4px;
|
||
text-align: center;
|
||
margin: 0 8px;
|
||
}
|
||
|
||
/* Drawer styles */
|
||
.drawer-mask {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 1000;
|
||
background-color: rgba(0,0,0,0.5);
|
||
}
|
||
|
||
.drawer-content {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 50%;
|
||
background-color: #fff;
|
||
transform: translateX(100%);
|
||
transition: transform 0.3s ease-in-out;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.drawer-show {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.drawer-header {
|
||
padding: 20px 24px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.drawer-title { font-size: 16px; font-weight: bold; }
|
||
.drawer-close { font-size: 20px; color: #999; cursor: pointer; }
|
||
|
||
.drawer-body { flex: 1; padding: 24px; }
|
||
|
||
.form-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-label { width: 100px; font-size: 14px; color: #666; }
|
||
.form-value { font-size: 14px; color: #333; }
|
||
|
||
.drawer-input {
|
||
flex: 1;
|
||
height: 36px;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 4px;
|
||
padding: 0 12px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.drawer-footer {
|
||
padding: 16px 24px;
|
||
border-top: 1px solid #f0f0f0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.btn-footer-cancel, .btn-footer-submit {
|
||
height: 32px;
|
||
line-height: 32px;
|
||
padding: 0 20px;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
margin-left: 12px;
|
||
}
|
||
|
||
.btn-footer-cancel { background-color: #fff; border: 1px solid #dcdfe6; color: #666; }
|
||
.btn-footer-submit { background-color: #1890ff; color: #fff; border: none; }
|
||
|
||
.empty-box { padding: 40px 0; text-align: center; }
|
||
.empty-text { font-size: 13px; color: #999; }
|
||
|
||
.row-center { display: flex; flex-direction: row; justify-content: center; align-items: center; }
|
||
.flex-1 { flex: 1; }
|
||
.flex-2 { flex: 2; }
|
||
.flex-3 { flex: 3; }
|
||
</style>
|