Files
medical-mall/pages/mall/admin/product/labels/index.uvue
2026-02-13 17:29:50 +08:00

601 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>