admin模块接入数据库

This commit is contained in:
comlibmb
2026-02-13 17:29:50 +08:00
parent 56209b7a75
commit ec636dc703
58 changed files with 5586 additions and 1394 deletions

View File

@@ -5,18 +5,23 @@
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">文章分类:</text>
<view class="select-mock">
<text class="select-val">{{ filterCategory }}</text>
<text class="arrow-down">▼</text>
</view>
<picker :value="categoryIndex" :range="categoryOptions" range-key="label" @change="onFilterCategoryChange">
<view class="select-mock">
<text class="select-val">{{ categoryOptions[categoryIndex]?.label || '全部' }}</text>
<text class="arrow-down">▼</text>
</view>
</picker>
</view>
<view class="filter-item">
<text class="label-txt">文章搜索:</text>
<input class="search-input" placeholder="请输入" v-model="filterKeyword" />
<input class="search-input" placeholder="请输入标题搜索" v-model="filterKeyword" @confirm="handleQuery" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="query-txt">查询</text>
</view>
<view class="btn-reset" @click="handleReset">
<text class="reset-txt">重置</text>
</view>
</view>
<!-- 主要内容区域 -->
@@ -29,43 +34,57 @@
<!-- 数据表格 -->
<view class="table-header">
<view class="th col-id"><text class="th-txt">ID</text></view>
<view class="th col-img"><text class="th-txt">文章图片</text></view>
<view class="th col-name"><text class="th-txt">文章名称</text></view>
<view class="th col-link"><text class="th-txt">关联商品</text></view>
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-img"><text class="th-txt">封面图</text></view>
<view class="th col-name"><text class="th-txt">文章标题</text></view>
<view class="th col-v"><text class="th-txt">浏览量</text></view>
<view class="th col-time"><text class="th-txt">时间</text></view>
<view class="th col-time"><text class="th-txt">最后更新</text></view>
<view class="th col-status"><text class="th-txt">状态</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view class="table-row" v-for="item in articleList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ item.id }}</text></view>
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="articleList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无文章数据</text>
</view>
<view v-else class="table-row" v-for="(item, index) in articleList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-img">
<view class="img-box"><text class="img-placeholder">🖼️</text></view>
<image v-if="item.image" :src="item.image" mode="aspectFill" class="article-cover"></image>
<view v-else class="img-placeholder"><text class="p-txt">无图</text></view>
</view>
<view class="td col-name">
<view class="title-box">
<text class="td-txt ellipsis-2">{{ item.title }}</text>
<text class="sub-txt">分类: {{ item.category_name }}</text>
</view>
</view>
<view class="td col-name"><text class="td-txt">{{ item.name }}</text></view>
<view class="td col-link"><text class="td-txt">{{ item.linkedProduct }}</text></view>
<view class="td col-v"><text class="td-txt">{{ item.views }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.time }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.updated_at.substring(0, 16).replace('T', ' ') }}</text></view>
<view class="td col-status">
<view :class="['switch-mock', item.status === 1 ? 'active' : '']" @click="toggleStatus(item)">
<view class="switch-handle"></view>
</view>
</view>
<view class="td col-op">
<view class="op-links">
<text class="link-txt" @click="handleEdit(item)">编辑</text>
<view class="divider"></view>
<text class="link-txt">关联</text>
<view class="divider"></view>
<text class="link-txt danger">删除</text>
<view class="divider"></view>
<text class="link-txt">复制链接</text>
<text class="arrow-small">▼</text>
<text class="link-txt danger" @click="handleDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<view class="pagination-bar">
<view class="page-info">
<text class="page-total">共 {{ articleList.length }} 条</text>
<text class="page-total">共 {{ total }} 条</text>
<view class="page-nav">
<view class="nav-btn" :class="{ disabled: page <= 1 }" @click="prevPage"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">{{ page }}</text></view>
<view class="nav-btn" :class="{ disabled: page >= totalPages }" @click="nextPage"><text class="nav-icon"> > </text></view>
</view>
</view>
</view>
@@ -76,131 +95,64 @@
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<view class="tab-item active">
<text class="tab-txt">文章信息</text>
<text class="tab-txt">{{ isEdit ? '编辑文章' : '添加文章' }}</text>
<view class="tab-line"></view>
</view>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<scroll-view class="drawer-body" :scroll-y="true">
<!-- 文章信息区块 -->
<view class="section-title">
<view class="title-inner active">
<text class="title-txt">文章信息</text>
<view class="title-line"></view>
</view>
</view>
<view class="form-grid">
<view class="form-row">
<view class="form-col">
<view class="label-box"><text class="required">*</text><text class="label-txt">标题:</text></view>
<view class="input-box">
<input class="input-base" v-model="formTitle" placeholder="请输入" />
<text class="input-count">{{ formTitle.length }}/80</text>
</view>
</view>
<view class="form-col">
<view class="label-box"><text class="label-txt">作者:</text></view>
<view class="input-box">
<input class="input-base" v-model="formAuthor" placeholder="请输入" />
<text class="input-count">{{ formAuthor.length }}/10</text>
</view>
<view class="form-item">
<view class="label-box"><text class="required">*</text><text class="label-txt">标题:</text></view>
<view class="input-box">
<input class="input-base" v-model="form.title" placeholder="请输入文章标题" />
</view>
</view>
<view class="form-row mt-20">
<view class="form-col">
<view class="label-box"><text class="required">*</text><text class="label-txt">文章分类:</text></view>
<view class="input-box">
<view class="form-item">
<view class="label-box"><text class="required">*</text><text class="label-txt">文章分类:</text></view>
<view class="input-box">
<picker :value="formCategoryIndex" :range="categoryOptions" range-key="label" @change="onFormCategoryChange">
<view class="select-mock">
<text class="select-val">{{ formCategory || '请选择' }}</text>
<text class="select-val">{{ categoryOptions[formCategoryIndex]?.label || '请选择分类' }}</text>
<text class="arrow-down">▼</text>
</view>
</view>
</view>
<view class="form-col">
<view class="label-box"><text class="label-txt">文章简介:</text></view>
<view class="input-box">
<textarea class="textarea-base" v-model="formIntro" placeholder="请输入" />
<text class="input-count">{{ formIntro.length }}/300</text>
</view>
</picker>
</view>
</view>
<view class="form-row mt-20">
<view class="form-col full">
<view class="label-box"><text class="required">*</text><text class="label-txt">图文封面:</text></view>
<view class="upload-container">
<view class="upload-btn">
<text class="plus-icon">+</text>
</view>
<text class="tip-txt mt-10">建议尺寸500 x 312 px</text>
</view>
</view>
</view>
</view>
<!-- 文章内容区块 -->
<view class="section-title mt-40">
<view class="title-inner active">
<text class="title-txt">文章内容</text>
<view class="title-line"></view>
</view>
</view>
<view class="editor-section">
<view class="label-box mb-10"><text class="required">*</text><text class="label-txt">文章内容:</text></view>
<view class="rich-editor-mock">
<view class="editor-toolbar">
<text class="tool-ic">HTML</text>
<text class="tool-ic">H</text>
<text class="tool-ic">B</text>
<text class="tool-ic">T↕</text>
<text class="tool-ic">F</text>
<text class="tool-ic">I</text>
<text class="tool-ic">U</text>
<text class="tool-ic">S</text>
<text class="tool-ic-img">🖼️</text>
<text class="tool-ic-img">🎬</text>
</view>
<view class="editor-content"></view>
</view>
</view>
<!-- 其他设置 -->
<view class="section-title mt-40">
<view class="title-inner active">
<text class="title-txt">其他设置</text>
<view class="title-line"></view>
</view>
</view>
<view class="settings-section">
<view class="form-item">
<view class="label-box-wide"><text class="label-txt">banner显示:</text></view>
<view class="radio-group">
<view class="radio-item" @click="formBanner = true">
<view :class="['radio-circle', formBanner ? 'checked' : '']"><view v-if="formBanner" class="radio-in"></view></view>
<text class="radio-la">显示</text>
</view>
<view class="radio-item" @click="formBanner = false">
<view :class="['radio-circle', !formBanner ? 'checked' : '']"><view v-if="!formBanner" class="radio-in"></view></view>
<text class="radio-la">不显示</text>
</view>
<view class="label-box"><text class="label-txt">作者:</text></view>
<view class="input-box">
<input class="input-base" v-model="form.author" placeholder="请输入作者" />
</view>
</view>
<view class="form-item mt-20">
<view class="label-box-wide"><text class="label-txt">热门文章:</text></view>
<view class="form-item align-start">
<view class="label-box pt-10"><text class="label-txt">文章简介:</text></view>
<view class="input-box">
<textarea class="textarea-base" v-model="form.description" placeholder="请输入简要说明" />
</view>
</view>
<view class="form-item align-start">
<view class="label-box pt-10"><text class="required">*</text><text class="label-txt">文章内容:</text></view>
<view class="input-box">
<textarea class="textarea-base content-area" v-model="form.content" placeholder="请输入文章正文" />
</view>
</view>
<view class="form-item">
<view class="label-box"><text class="label-txt">发布状态:</text></view>
<view class="radio-group">
<view class="radio-item" @click="formHot = true">
<view :class="['radio-circle', formHot ? 'checked' : '']"><view v-if="formHot" class="radio-in"></view></view>
<text class="radio-la">显示</text>
<view class="radio-item" @click="form.status = 1">
<view :class="['radio-circle', form.status === 1 ? 'checked' : '']"><view v-if="form.status === 1" class="radio-in"></view></view>
<text class="radio-la">立即发布</text>
</view>
<view class="radio-item" @click="formHot = false">
<view :class="['radio-circle', !formHot ? 'checked' : '']"><view v-if="!formHot" class="radio-in"></view></view>
<text class="radio-la">不显示</text>
<view class="radio-item" @click="form.status = 0">
<view :class="['radio-circle', form.status === 0 ? 'checked' : '']"><view v-if="form.status === 0" class="radio-in"></view></view>
<text class="radio-la">暂不发布</text>
</view>
</view>
</view>
@@ -218,145 +170,289 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import {
fetchArticlePage,
fetchArticleCategoryPage,
saveArticle,
deleteArticle,
setArticleStatus,
fetchArticleDetail,
type ArticleItem,
type ArticleCategory
} from '@/services/admin/cmsService.uts'
const filterCategory = ref('全部')
const filterKeyword = ref('')
const articleList = ref([
{ id: '240', name: '赋能消费 | 卷狗优选迈向文化消费新时代', linkedProduct: '', views: '3349', time: '2025-04-01 16:34' },
{ id: '237', name: '把重要的日子放在桌面', linkedProduct: '2024新款吹风机...', views: '260', time: '2025-04-01 16:32' }
])
const articleList = ref<ArticleItem[]>([])
const categories = ref<ArticleCategory[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const categoryIndex = ref(0)
const categoryOptions = computed(() : any[] => {
const opts = [{ label: '全部分类', value: null }]
categories.value.forEach(c => {
opts.push({ label: c.name, value: c.id })
})
return opts
})
const showDrawer = ref(false)
const isClosing = ref(false)
const formTitle = ref('')
const formAuthor = ref('')
const formCategory = ref('')
const formIntro = ref('')
const formBanner = ref(false)
const formHot = ref(false)
const isEdit = ref(false)
const formCategoryIndex = ref(0)
const handleAdd = () => {
formTitle.value = ''
formAuthor.value = ''
formCategory.value = ''
formIntro.value = ''
formBanner.value = false
formHot.value = false
const form = reactive({
id: '' as string | null,
category_id: '',
title: '',
author: '',
description: '',
content: '',
status: 1,
image: null as string | null
})
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
onMounted(() => {
loadCategories()
loadData()
})
async function loadCategories() {
const res = await fetchArticleCategoryPage(1, 100) // 获取所有分类用于筛选
categories.value = res.items
}
async function loadData() {
loading.value = true
try {
const catId = categoryOptions.value[categoryIndex.value]?.value as string | null
const res = await fetchArticlePage(
page.value,
pageSize.value,
catId,
null,
filterKeyword.value || null
)
articleList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function handleQuery() {
page.value = 1
loadData()
}
function handleReset() {
filterKeyword.value = ''
categoryIndex.value = 0
page.value = 1
loadData()
}
function onFilterCategoryChange(e : any) {
categoryIndex.value = e.detail.value as number
handleQuery()
}
function onFormCategoryChange(e : any) {
formCategoryIndex.value = e.detail.value as number
form.category_id = categoryOptions.value[formCategoryIndex.value].value as string
}
function handleAdd() {
isEdit.value = false
form.id = null
form.title = ''
form.author = ''
form.category_id = ''
form.description = ''
form.content = ''
form.status = 1
formCategoryIndex.value = 0
isClosing.value = false
showDrawer.value = true
}
const handleEdit = (item: any) => {
formTitle.value = item.name
formAuthor.value = '管理员'
formCategory.value = '全部'
formIntro.value = '这是一段文章简介...'
formBanner.value = false
formHot.value = true
isClosing.value = false
showDrawer.value = true
async function handleEdit(item : ArticleItem) {
loading.value = true
try {
const detail = await fetchArticleDetail(item.id)
if (detail != null) {
isEdit.value = true
form.id = detail.id
form.title = detail.title
form.author = detail.author || ''
form.category_id = detail.category_id
form.description = detail.description || ''
form.content = detail.content
form.status = detail.status
const idx = categoryOptions.value.findIndex(opt => opt.value == detail.category_id)
formCategoryIndex.value = idx > -1 ? idx : 0
isClosing.value = false
showDrawer.value = true
}
} catch (e) {
uni.showToast({ title: '获取详情失败', icon: 'none' })
} finally {
loading.value = false
}
}
const closeDrawer = () => {
async function toggleStatus(item : ArticleItem) {
const target = item.status === 1 ? 0 : 1
const ok = await setArticleStatus(item.id, target)
if (ok) {
item.status = target
uni.showToast({ title: '状态已更新' })
}
}
async function handleDelete(item : ArticleItem) {
uni.showModal({
title: '提示',
content: `确定删除文章 "${item.title}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteArticle(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function handleConfirm() {
if (!form.title || !form.category_id || !form.content) {
uni.showToast({ title: '请完善文章必填信息', icon: 'none' })
return
}
const resId = await saveArticle(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeDrawer()
loadData()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
function closeDrawer() {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
const handleConfirm = () => { closeDrawer() }
const handleQuery = () => { console.log('Querying...') }
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (page.value < totalPages.value) { page.value++; loadData(); } }
</script>
<style scoped lang="scss">
.admin-cms-article { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 30px; margin-bottom: 20px; }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 15px; margin-bottom: 20px; }
.filter-item { display: flex; flex-direction: row; align-items: center; }
.label-txt { font-size: 14px; color: #606266; margin-right: 12px; }
.select-mock { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 15px; }
.select-mock { min-width: 160px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 15px; }
.select-val { font-size: 13px; color: #333; }
.arrow-down { font-size: 10px; color: #999; }
.search-input { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 15px; font-size: 13px; }
.btn-query { width: 76px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.btn-query { padding: 0 20px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-reset { padding: 0 20px; height: 34px; background-color: #fff; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.query-txt { color: #fff; font-size: 14px; }
.reset-txt { color: #606266; font-size: 14px; }
.table-card { display: flex; flex-direction: column; }
.card-header { padding: 20px; }
.btn-primary { width: 100px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.btn-primary { width: 100px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-txt { color: #fff; font-size: 13px; }
.table-header { height: 50px; background-color: #eaf2ff; display: flex; flex-direction: row; align-items: center; }
.th { padding: 0 15px; }
.th-txt { font-size: 13px; color: #606266; font-weight: bold; }
.table-row { height: 80px; display: flex; flex-direction: row; align-items: center; border-bottom: 1px solid #f0f0f0; }
.td { padding: 0 15px; }
.td-txt { font-size: 13px; color: #606266; }
.img-box { width: 60px; height: 40px; background-color: #f5f7fa; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.img-placeholder { font-size: 20px; }
.col-id { width: 80px; justify-content: center; }
.col-img { width: 100px; }
.col-name { flex: 2; }
.col-link { flex: 2; }
.col-v { width: 100px; justify-content: center; }
.col-time { width: 180px; justify-content: center; }
.col-op { width: 220px; }
.article-cover { width: 60px; height: 40px; border-radius: 4px; background-color: #f5f7fa; }
.img-placeholder { width: 60px; height: 40px; background-color: #f5f7fa; border-radius: 4px; display: flex; align-items: center; justify-content: center; .p-txt { font-size: 10px; color: #ccc; } }
.title-box { display: flex; flex-direction: column; gap: 4px; .sub-txt { font-size: 11px; color: #999; } }
.ellipsis-2 { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.col-id { width: 60px; justify-content: center; }
.col-img { width: 90px; }
.col-name { flex: 1; }
.col-v { width: 80px; justify-content: center; }
.col-time { width: 160px; justify-content: center; }
.col-status { width: 100px; justify-content: center; }
.col-op { width: 180px; }
.switch-mock { width: 40px; height: 20px; background-color: #dcdfe6; border-radius: 10px; position: relative; transition: all 0.3s; cursor: pointer; }
.switch-mock.active { background-color: #2d8cf0; }
.switch-handle { width: 16px; height: 16px; background-color: #fff; border-radius: 8px; position: absolute; top: 2px; left: 2px; transition: all 0.3s; }
.active .switch-handle { left: 22px; }
.op-links { display: flex; flex-direction: row; align-items: center; }
.link-txt { font-size: 13px; color: #2d8cf0; cursor: pointer; }
.danger { color: #ed4014; }
.divider { width: 1px; height: 12px; background-color: #e8eaec; margin: 0 8px; }
.arrow-small { font-size: 8px; color: #2d8cf0; margin-left: 2px; }
.pagination-bar { padding: 20px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.page-info { display: flex; flex-direction: row; align-items: center; }
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; transition: opacity 0.3s; }
.page-nav { display: flex; flex-direction: row; align-items: center; }
.nav-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; cursor: pointer; }
.nav-item { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; }
.nav-item.active { background-color: #2d8cf0; border-color: #2d8cf0; .nav-num { color: #fff; } }
.nav-num, .nav-icon { font-size: 13px; color: #606266; }
.disabled { opacity: 0.5; cursor: not-allowed; }
/* Drawer Drawer */
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; }
.mask-fade-out { opacity: 0; }
.drawer-content { position: absolute; top: 0; right: 0; width: 50%; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
.drawer-content { position: absolute; top: 0; right: 0; width: 60%; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
.slide-out { animation: slideOut 0.3s ease forwards; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes slideOut { from { transform: translateX(0); } to { transform: translateX(100%); } }
.drawer-header { height: 54px; padding: 0 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.tab-item { height: 100%; display: flex; align-items: center; position: relative; }
.tab-txt { font-size: 15px; color: #2d8cf0; font-weight: 500; }
.tab-line { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background-color: #2d8cf0; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.drawer-body { flex: 1; padding: 24px; background-color: #fff; }
.section-title { height: 44px; border-bottom: 1px solid #f0f0f0; margin-bottom: 24px; display: flex; flex-direction: row; }
.title-inner { height: 100%; display: flex; align-items: center; position: relative; padding: 0 10px; }
.title-txt { font-size: 15px; color: #333; font-weight: 500; }
.title-inner.active .title-txt { color: #2d8cf0; }
.title-line { position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background-color: #2d8cf0; display: none; }
.title-inner.active .title-line { display: block; }
.form-grid { display: flex; flex-direction: column; }
.form-row { display: flex; flex-direction: row; gap: 30px; }
.form-col { flex: 1; display: flex; flex-direction: row; align-items: flex-start; }
.form-col.full { flex: none; width: 100%; }
.label-box { width: 100px; display: flex; flex-direction: row; justify-content: flex-end; margin-right: 15px; padding-top: 8px; flex-shrink: 0; }
.label-box-wide { width: 120px; display: flex; flex-direction: row; justify-content: flex-end; margin-right: 15px; flex-shrink: 0; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; padding: 5px; }
.drawer-body { flex: 1; padding: 24px 30px; background-color: #fff; }
.form-grid { display: flex; flex-direction: column; gap: 24px; }
.form-item { display: flex; flex-direction: row; align-items: center; }
.align-start { align-items: flex-start; }
.label-box { width: 100px; display: flex; flex-direction: row; justify-content: flex-end; margin-right: 15px; flex-shrink: 0; }
.pt-10 { padding-top: 10px; }
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; }
.input-box { flex: 1; position: relative; }
.input-base { width: 100%; height: 38px; border: 1px solid #dcdee2; border-radius: 4px; padding: 0 45px 0 12px; font-size: 14px; }
.textarea-base { width: 100%; height: 100px; border: 1px solid #dcdee2; border-radius: 4px; padding: 10px 12px; font-size: 14px; }
.input-count { position: absolute; bottom: 8px; right: 12px; font-size: 12px; color: #999; }
.upload-container { display: flex; flex-direction: column; }
.upload-btn { width: 80px; height: 80px; border: 1px dashed #dcdee2; border-radius: 4px; display: flex; align-items: center; justify-content: center; background-color: #f8f8f9; }
.plus-icon { font-size: 30px; color: #999; }
.tip-txt { font-size: 12px; color: #999; }
.editor-section { display: flex; flex-direction: column; }
.rich-editor-mock { border: 1px solid #dcdee2; border-radius: 4px; min-height: 400px; display: flex; flex-direction: column; }
.editor-toolbar { height: 44px; background-color: #fafafa; border-bottom: 1px solid #dcdee2; display: flex; flex-direction: row; align-items: center; padding: 0 15px; gap: 20px; flex-wrap: wrap; }
.tool-ic { font-size: 14px; color: #515a6e; cursor: pointer; }
.tool-ic-img { font-size: 18px; cursor: pointer; }
.editor-content { flex: 1; background-color: #fff; }
.settings-section { display: flex; flex-direction: column; }
.form-item { display: flex; flex-direction: row; align-items: center; }
.input-box { flex: 1; }
.input-base { width: 100%; height: 38px; border: 1px solid #dcdee2; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.textarea-base { width: 100%; height: 80px; border: 1px solid #dcdee2; border-radius: 4px; padding: 10px 12px; font-size: 14px; }
.content-area { height: 300px; }
.radio-group { display: flex; flex-direction: row; gap: 30px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
@@ -365,18 +461,7 @@ const handleQuery = () => { console.log('Querying...') }
.radio-in { width: 8px; height: 8px; background-color: #2d8cf0; border-radius: 50%; }
.radio-la { font-size: 14px; color: #606266; }
.submit-container { display: flex; justify-content: flex-start; padding-left: 135px; padding-bottom: 50px; }
.btn-submit { width: 120px; height: 40px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.submit-container { display: flex; justify-content: flex-end; padding-bottom: 40px; }
.btn-submit { width: 100px; height: 38px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.submit-txt { color: #fff; font-size: 14px; }
.drawer-footer { height: 60px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; align-items: center; padding: 0 24px; background-color: #fff; }
.btn-cancel { padding: 8px 20px; border: 1px solid #dcdee2; border-radius: 4px; margin-right: 15px; }
.btn-confirm { padding: 8px 20px; background-color: #2d8cf0; border-radius: 4px; }
.cancel-txt { font-size: 14px; color: #606266; }
.confirm-txt { font-size: 14px; color: #fff; }
.mt-20 { margin-top: 20px; }
.mt-40 { margin-top: 40px; }
.mt-10 { margin-top: 10px; }
.mb-10 { margin-bottom: 10px; }
</style>

View File

@@ -3,20 +3,16 @@
<view class="content-body">
<!-- 顶部过滤栏 -->
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">是否显示:</text>
<view class="select-mock">
<text class="select-val">请选择</text>
<text class="arrow-down">▼</text>
</view>
</view>
<view class="filter-item">
<text class="label-txt">分类名称:</text>
<input class="search-input" placeholder="请输入分类名称" v-model="filterKeyword" />
<input class="search-input" placeholder="请输入分类名称" v-model="filterKeyword" @confirm="handleQuery" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="query-txt">查询</text>
</view>
<view class="btn-reset" @click="handleReset">
<text class="reset-txt">重置</text>
</view>
</view>
<!-- 主要内容区域 -->
@@ -29,22 +25,31 @@
<!-- 数据表格 -->
<view class="table-header">
<view class="th col-id"><text class="th-txt">ID</text></view>
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-name"><text class="th-txt">分类名称</text></view>
<view class="th col-img"><text class="th-txt">分类图</text></view>
<view class="th col-img"><text class="th-txt">分类图</text></view>
<view class="th col-sort"><text class="th-txt">排序</text></view>
<view class="th col-status"><text class="th-txt">状态</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view class="table-row" v-for="item in categoryList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ item.id }}</text></view>
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="categoryList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无分类数据</text>
</view>
<view v-else v-for="(item, index) in categoryList" :key="item.id" class="table-row">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-name"><text class="td-txt">{{ item.name }}</text></view>
<view class="td col-img">
<view class="img-box-placeholder"></view>
<image v-if="item.icon" :src="item.icon" mode="aspectFit" class="cate-icon"></image>
<view v-else class="img-placeholder"></view>
</view>
<view class="td col-sort"><text class="td-txt">{{ item.sort }}</text></view>
<view class="td col-status">
<view :class="['switch-mock', item.status ? 'active' : '']" @click="toggleStatus(item)">
<view :class="['switch-mock', item.status === 1 ? 'active' : '']" @click="toggleStatus(item)">
<view class="switch-handle"></view>
</view>
</view>
@@ -52,13 +57,21 @@
<view class="op-links">
<text class="link-txt" @click="handleEdit(item)">编辑</text>
<view class="divider"></view>
<text class="link-txt danger">删除</text>
<view class="divider"></view>
<text class="link-txt">查看文章</text>
<text class="link-txt danger" @click="handleDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页 -->
<view class="pagination-bar">
<text class="page-total">共 {{ total }} 条</text>
<view class="page-nav">
<view class="nav-btn" :class="{ disabled: page <= 1 }" @click="prevPage"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">{{ page }}</text></view>
<view class="nav-btn" :class="{ disabled: page >= totalPages }" @click="nextPage"><text class="nav-icon"> > </text></view>
</view>
</view>
</view>
</view>
@@ -66,21 +79,11 @@
<view v-if="showDrawer" :class="['drawer-mask', isClosing ? 'mask-fade-out' : '']" @click="closeDrawer">
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<text class="drawer-title">添加分类</text>
<text class="drawer-title">{{ isEdit ? '编辑分类' : '添加分类' }}</text>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<scroll-view class="drawer-body" :scroll-y="true">
<view class="form-item row">
<view class="label-box"><text class="label-txt">上级分类:</text></view>
<view class="input-box">
<view class="select-mock">
<text class="select-val">顶级分类</text>
<text class="arrow-down">▼</text>
</view>
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="required">*</text><text class="label-txt">分类名称:</text></view>
<view class="input-box">
@@ -88,22 +91,6 @@
</view>
</view>
<view class="form-item row align-start">
<view class="label-box pt-10"><text class="required">*</text><text class="label-txt">分类简介:</text></view>
<view class="input-box">
<textarea class="textarea-mini" v-model="formDesc" placeholder="请输入分类简介" />
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="label-txt">分类图片:</text></view>
<view class="input-box">
<view class="upload-btn">
<view class="img-icon">🖼️</view>
</view>
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="label-txt">排序:</text></view>
<view class="input-box">
@@ -136,131 +123,253 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted, computed } from 'vue'
import {
fetchArticleCategoryPage,
saveArticleCategory,
deleteArticleCategory,
setArticleCategoryStatus,
type ArticleCategory
} from '@/services/admin/cmsService.uts'
const filterKeyword = ref('')
const categoryList = ref([
{ id: '181', name: '购物心得', status: true },
{ id: '180', name: '消费文化', status: true },
{ id: '179', name: '品牌资讯', status: true }
])
const categoryList = ref<ArticleCategory[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const loading = ref(false)
const jumpPage = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
const showDrawer = ref(false)
const isClosing = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
const formName = ref('')
const formDesc = ref('')
const formSort = ref(0)
const formStatus = ref(true)
const formIcon = ref<string | null>(null)
const handleAdd = () => {
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchArticleCategoryPage(page.value, pageSize.value, filterKeyword.value || null)
categoryList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载分类失败', icon: 'none' })
} finally {
loading.value = false
}
}
function handleQuery() {
page.value = 1
loadData()
}
function handleReset() {
filterKeyword.value = ''
page.value = 1
loadData()
}
function handleAdd() {
isEdit.value = false
editingId.value = null
formName.value = ''
formDesc.value = ''
formSort.value = 0
formStatus.value = true
formIcon.value = null
isClosing.value = false
showDrawer.value = true
}
const handleEdit = (item: any) => {
function handleEdit(item: ArticleCategory) {
isEdit.value = true
editingId.value = item.id
formName.value = item.name
// 模拟填充其他字段
formDesc.value = ''
formSort.value = 0
formStatus.value = item.status
formSort.value = item.sort
formStatus.value = item.status === 1
formIcon.value = item.icon
isClosing.value = false
showDrawer.value = true
}
const toggleStatus = (item: any) => {
item.status = !item.status
async function toggleStatus(item: ArticleCategory) {
const targetStatus = item.status === 1 ? 0 : 1
const ok = await setArticleCategoryStatus(item.id, targetStatus)
if (ok) {
item.status = targetStatus
uni.showToast({ title: '状态已更新' })
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const closeDrawer = () => {
async function handleDelete(item: ArticleCategory) {
uni.showModal({
title: '提示',
content: `确定要删除分类 "${item.name}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteArticleCategory(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
function closeDrawer() {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
const handleConfirm = () => { closeDrawer() }
const handleQuery = () => { console.log('Querying...') }
async function handleConfirm() {
if (!formName.value) {
uni.showToast({ title: '请输入分类名称', icon: 'none' })
return
}
try {
const resId = await saveArticleCategory(
editingId.value,
formName.value,
formIcon.value,
formSort.value,
formStatus.value ? 1 : 0
)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeDrawer()
loadData()
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
function prevPage() {
if (page.value > 1) {
page.value--
loadData()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value++
loadData()
}
}
function goToJumpPage() {
const target = parseInt(jumpPage.value)
if (!isNaN(target) && target >= 1 && target <= totalPages.value) {
page.value = target
loadData()
jumpPage.value = ''
} else {
uni.showToast({ title: '页码无效', icon: 'none' })
}
}
</script>
<style scoped lang="scss">
.admin-cms-category { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 30px; margin-bottom: 20px; }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 15px; margin-bottom: 20px; }
.filter-item { display: flex; flex-direction: row; align-items: center; }
.label-txt { font-size: 14px; color: #606266; margin-right: 12px; }
.select-mock { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 15px; }
.select-val { font-size: 13px; color: #c0c4cc; }
.arrow-down { font-size: 10px; color: #999; }
.search-input { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 15px; font-size: 13px; }
.btn-query { width: 76px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.btn-query { padding: 0 20px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-reset { padding: 0 20px; height: 34px; background-color: #fff; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.query-txt { color: #fff; font-size: 14px; }
.reset-txt { color: #606266; font-size: 14px; }
.table-card { display: flex; flex-direction: column; }
.card-header { padding: 20px; }
.btn-primary { width: 140px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.btn-primary { width: 140px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-txt { color: #fff; font-size: 13px; }
.table-header { height: 50px; background-color: #eaf2ff; display: flex; flex-direction: row; align-items: center; }
.th { padding: 0 15px; }
.th-txt { font-size: 13px; color: #606266; font-weight: bold; }
.table-row { height: 60px; display: flex; flex-direction: row; align-items: center; border-bottom: 1px solid #f0f0f0; }
.td { padding: 0 15px; }
.td-txt { font-size: 13px; color: #606266; }
.col-id { width: 80px; justify-content: center; }
.col-name { flex: 1; justify-content: flex-start; }
.col-img { flex: 1; justify-content: center; }
.col-img { width: 120px; justify-content: center; }
.col-sort { width: 100px; justify-content: center; }
.col-status { width: 120px; justify-content: center; }
.col-op { width: 220px; justify-content: center; }
.img-box-placeholder { width: 40px; height: 40px; background-color: #f5f7fa; border-radius: 4px; }
.switch-mock { width: 40px; height: 20px; background-color: #dcdfe6; border-radius: 10px; position: relative; transition: all 0.3s; }
.col-op { width: 180px; justify-content: center; }
.cate-icon { width: 40px; height: 40px; border-radius: 4px; background-color: #f5f7fa; }
.img-placeholder { width: 40px; height: 40px; background-color: #f5f7fa; border-radius: 4px; }
.switch-mock { width: 40px; height: 20px; background-color: #dcdfe6; border-radius: 10px; position: relative; transition: all 0.3s; cursor: pointer; }
.switch-mock.active { background-color: #2d8cf0; }
.switch-handle { width: 16px; height: 16px; background-color: #fff; border-radius: 8px; position: absolute; top: 2px; left: 2px; transition: all 0.3s; }
.active .switch-handle { left: 22px; }
.op-links { display: flex; flex-direction: row; align-items: center; }
.link-txt { font-size: 13px; color: #2d8cf0; cursor: pointer; }
.danger { color: #ed4014; }
.divider { width: 1px; height: 12px; background-color: #e8eaec; margin: 0 10px; }
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; transition: opacity 0.3s; }
.mask-fade-out { opacity: 0; }
.drawer-content { position: absolute; top: 0; right: 0; width: 50%; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
.slide-out { animation: slideOut 0.3s ease forwards; }
.pagination-bar { padding: 20px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.page-nav { display: flex; flex-direction: row; align-items: center; }
.nav-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; cursor: pointer; }
.nav-item { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; }
.nav-item.active { background-color: #2d8cf0; border-color: #2d8cf0; }
.nav-item.active .nav-num { color: #fff; }
.nav-num, .nav-icon { font-size: 13px; color: #606266; }
.disabled { opacity: 0.5; cursor: not-allowed; }
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; }
.drawer-content { position: absolute; top: 0; right: 0; width: 450px; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.slide-out { animation: slideOut 0.3s ease forwards; }
@keyframes slideOut { from { transform: translateX(0); } to { transform: translateX(100%); } }
.drawer-header { height: 60px; padding: 0 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; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; padding: 5px; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.drawer-body { flex: 1; padding: 24px 30px; }
.form-item { margin-bottom: 24px; display: flex; flex-direction: row; align-items: center; }
.label-box { width: 120px; display: flex; justify-content: flex-end; align-items: center; margin-right: 20px; flex-shrink: 0; }
.label-box { width: 100px; display: flex; justify-content: flex-end; align-items: center; margin-right: 20px; flex-shrink: 0; }
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; text-align: right; }
.label-txt { font-size: 14px; color: #606266; }
.input-box { flex: 1; }
.input-base { width: 100%; height: 38px; border: 1px solid #dcdee2; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.textarea-mini { width: 100%; height: 80px; border: 1px solid #dcdee2; border-radius: 4px; padding: 10px 12px; font-size: 14px; }
.upload-btn {
width: 70px;
height: 70px;
border: 1px solid #dcdee2;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.img-icon { font-size: 30px; color: #ccc; }
.radio-group { display: flex; flex-direction: row; gap: 30px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-circle { width: 16px; height: 16px; border: 1px solid #dcdee2; border-radius: 50%; margin-right: 8px; display: flex; align-items: center; justify-content: center; }
.radio-circle.checked { border-color: #2d8cf0; }
.radio-in { width: 8px; height: 8px; background-color: #2d8cf0; border-radius: 50%; }
.radio-la { font-size: 14px; color: #606266; }
.drawer-footer { height: 60px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; align-items: center; padding: 0 24px; }
.btn-cancel { padding: 8px 20px; border: 1px solid #dcdee2; border-radius: 4px; margin-right: 15px; }
.btn-confirm { padding: 8px 20px; background-color: #2d8cf0; border-radius: 4px; }
.btn-cancel { padding: 8px 20px; border: 1px solid #dcdee2; border-radius: 4px; margin-right: 15px; cursor: pointer; }
.btn-confirm { padding: 8px 20px; background-color: #2d8cf0; border-radius: 4px; cursor: pointer; }
.cancel-txt { font-size: 14px; color: #606266; }
.confirm-txt { font-size: 14px; color: #fff; }
</style>

View File

@@ -17,7 +17,7 @@
</view>
<view class="content-card">
<view class="action-bar">
<button class="btn primary small" @click="onAdd">添加等级</button>
<button class="btn primary small" @click="openEditModal(null)">添加等级</button>
</view>
<view class="table-container">
<view class="table-header">
@@ -42,17 +42,17 @@
<view class="col col-level"><text>{{ item.level }}</text></view>
<view class="col col-percent"><text>{{ item.percent1 }}%</text></view>
<view class="col col-percent"><text>{{ item.percent2 }}%</text></view>
<view class="col col-stat"><text>{{ item.taskTotal }}</text></view>
<view class="col col-stat"><text>{{ item.taskFinish }}</text></view>
<view class="col col-stat"><text>{{ item.task_total }}</text></view>
<view class="col col-stat"><text>{{ item.task_finish }}</text></view>
<view class="col col-status">
<switch :checked="item.show" color="#1890ff" scale="0.8" />
<switch :checked="item.is_visible" color="#1890ff" scale="0.8" @change="() => onToggleVisible(item)" />
</view>
<view class="col col-ops">
<text class="op-link">等级任务</text>
<text class="op-divider">|</text>
<text class="op-link">编辑</text>
<text class="op-link" @click="openEditModal(item)">编辑</text>
<text class="op-divider">|</text>
<text class="op-link">删除</text>
<text class="op-link" @click="onDelete(item.id)">删除</text>
</view>
</view>
</view>
@@ -61,15 +61,171 @@
<text class="page-info">共 {{ levelList.length }} 条</text>
</view>
</view>
<view v-if="editPopupVisible" class="popup-mask" @click="closeEditModal">
<view class="popup-card" @click.stop>
<view class="popup-header">
<text class="popup-title">{{ editForm.id == null ? '添加分销等级' : '编辑分销等级' }}</text>
<text class="popup-close" @click="closeEditModal">×</text>
</view>
<view class="popup-body">
<view class="popup-item">
<text class="popup-label">等级名称</text>
<input v-model="editForm.name" class="popup-input" placeholder="如:一级分销员" />
</view>
<view class="popup-item">
<text class="popup-label">等级权重</text>
<input v-model="editForm.level" type="number" class="popup-input" placeholder="如1" />
</view>
<view class="popup-item">
<text class="popup-label">一级分佣比例 (%)</text>
<input v-model="editForm.percent1" type="digit" class="popup-input" placeholder="0 - 100" />
</view>
<view class="popup-item">
<text class="popup-label">二级分佣比例 (%)</text>
<input v-model="editForm.percent2" type="digit" class="popup-input" placeholder="0 - 100" />
</view>
<view class="popup-item">
<text class="popup-label">任务总数</text>
<input v-model="editForm.task_total" type="number" class="popup-input" placeholder="如0" />
</view>
<view class="popup-item">
<text class="popup-label">需完成数量</text>
<input v-model="editForm.task_finish" type="number" class="popup-input" placeholder="如0" />
</view>
<view class="popup-item popup-row">
<text class="popup-label">是否显示</text>
<switch :checked="!!editForm.is_visible" color="#1890ff" scale="0.8" @change="(e) => editForm.is_visible = e.detail.value" />
</view>
</view>
<view class="popup-footer">
<button class="btn" @click="closeEditModal">取消</button>
<button class="btn primary" @click="handleSave">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
const levelList = ref([
{ id: '1', name: '一级分销员', level: 1, percent1: 0.00, percent2: 0.00, taskTotal: 0, taskFinish: 0, show: true },
])
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
function onAdd() { uni.showToast({ title: '添加中...', icon: 'none' }) }
import { ref, onMounted, reactive } from 'vue'
import { getDistributionLevelList, saveDistributionLevel, deleteDistributionLevel, DistributionLevel } from '@/services/admin/distributionService.uts'
const levelList = ref<DistributionLevel[]>([])
const isLoading = ref(false)
const editPopupVisible = ref(false)
const editForm = reactive<DistributionLevel>({
id: undefined,
name: '',
level: 1,
percent1: 0,
percent2: 0,
task_total: 0,
task_finish: 0,
is_visible: true
})
onMounted(() => {
loadLevels()
})
async function loadLevels() {
isLoading.value = true
try {
const res = await getDistributionLevelList()
levelList.value = res
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function onSearch() {
loadLevels()
}
function openEditModal(item: DistributionLevel | null) {
if (item != null) {
Object.assign(editForm, item)
} else {
Object.assign(editForm, {
id: undefined,
name: '',
level: levelList.value.length + 1,
percent1: 0,
percent2: 0,
task_total: 0,
task_finish: 0,
is_visible: true
})
}
editPopupVisible.value = true
}
function closeEditModal() {
editPopupVisible.value = false
}
async function handleSave() {
if (!editForm.name) {
uni.showToast({ title: '请输入等级名称', icon: 'none' })
return
}
isLoading.value = true
try {
const success = await saveDistributionLevel(editForm as DistributionLevel)
if (success) {
uni.showToast({ title: '保存成功', icon: 'success' })
closeEditModal()
loadLevels()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '保存异常', icon: 'none' })
} finally {
isLoading.value = false
}
}
async function onDelete(id: string | undefined) {
if (id == null) return
uni.showModal({
title: '确认删除',
content: '确定要删除该分销等级吗?',
success: async (res) => {
if (res.confirm) {
isLoading.value = true
try {
const success = await deleteDistributionLevel(id)
if (success) {
uni.showToast({ title: '删除成功' })
loadLevels()
}
} finally {
isLoading.value = false
}
}
}
})
}
async function onToggleVisible(item: DistributionLevel) {
const updated = { ...item, is_visible: !item.is_visible } as DistributionLevel
const success = await saveDistributionLevel(updated)
if (success) {
loadLevels()
}
}
</script>
<style scoped lang="scss">
.admin-page { padding: 0; }
@@ -92,4 +248,97 @@ function onAdd() { uni.showToast({ title: '添加中...', icon: 'none' }) }
.op-divider { color: #e8e8e8; margin: 0 8px; }
.pagination { padding: 16px 24px; border-top: 1px solid #f0f0f0; }
.page-info { font-size: 14px; color: #999; }
/* 弹窗样式 */
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.popup-card {
width: 500px;
background-color: #fff;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.popup-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.popup-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.popup-close {
font-size: 20px;
color: #999;
cursor: pointer;
padding: 4px;
}
.popup-body {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.popup-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.popup-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.popup-label {
font-size: 14px;
color: #666;
}
.popup-input {
border: 1px solid #d9d9d9;
border-radius: 4px;
height: 36px;
padding: 0 12px;
font-size: 14px;
width: 100%;
}
.popup-footer {
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 12px;
}
.btn.ghost {
background-color: #fff;
color: #666;
border: 1px solid #d9d9d9;
}
</style>

View File

@@ -4,10 +4,12 @@
<view class="filter-row">
<view class="filter-item">
<text class="label">时间选择:</text>
<view class="date-picker-mock">
<text class="placeholder">开始日期 - 结束日期</text>
<text class="icon-calendar">📅</text>
</view>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
<view class="filter-item">
<text class="label">搜索:</text>
@@ -23,6 +25,11 @@
<button class="btn ghost small" @click="onExport">导出</button>
</view>
<view class="table-container">
<!-- Loading 遮罩 -->
<view v-if="isLoading" class="loading-mask">
<text class="loading-text">数据加载中...</text>
</view>
<view class="table-header">
<view class="col col-id"><text>ID</text></view>
<view class="col col-img"><text>头像</text></view>
@@ -38,16 +45,19 @@
<view class="col col-ops"><text>操作</text></view>
</view>
<view class="table-body">
<view v-if="promoterList.length === 0 && !isLoading" class="empty-row">
<text>暂无数据</text>
</view>
<view v-for="item in promoterList" :key="item.id" class="table-row">
<view class="col col-id"><text>{{ item.id }}</text></view>
<view class="col col-img">
<image class="table-img" src="/static/logo.png" mode="aspectFill" />
<image class="table-img" :src="item.avatar_url || '/static/logo.png'" mode="aspectFill" />
</view>
<view class="col col-info">
<view class="user-info-box">
<text class="info-text">昵称:{{ item.nickname }}</text>
<text class="info-text">姓名:{{ item.name }}</text>
<text class="info-text">电话:{{ item.phone }}</text>
<text class="info-text">姓名:{{ item.name || '-' }}</text>
<text class="info-text">电话:{{ item.phone || '-' }}</text>
</view>
</view>
<view class="col col-level"><text>{{ item.level }}</text></view>
@@ -68,21 +78,88 @@
</view>
</view>
<view class="pagination">
<text class="page-info">共 {{ promoterList.length }} 条</text>
<view class="pager-btns">
<button class="btn small" :disabled="page <= 1" @click="onPrevPage">上一页</button>
<text class="page-num">第 {{ page }} 页</text>
<button class="btn small" :disabled="promoterList.length < pageSize" @click="onNextPage">下一页</button>
</view>
<text class="page-info">当前页数据 {{ promoterList.length }} 条</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
const promoterList = ref([
{ id: '82764', nickname: '183****5762', name: '-', phone: '183****5762', level: '--', userCount: 0, orderCount: 0, orderAmount: '0.00', commissionTotal: '0.00', withdrawnAmount: 0, withdrawCount: 0, unwithdrawnAmount: 0 },
])
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
function onExport() { uni.showToast({ title: '开始导出', icon: 'none' }) }
function onPromoter(item: any) { uni.showToast({ title: '推广人: ' + item.id, icon: 'none' }) }
function onMore(item: any) { uni.showToast({ title: '更多: ' + item.id, icon: 'none' }) }
import { ref, onMounted } from 'vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { getPromoterList, Promoter, PromoterListParams } from '@/services/admin/distributionService.uts'
const promoterList = ref<Promoter[]>([])
const isLoading = ref(false)
const searchQuery = ref('')
const page = ref(1)
const pageSize = 20
const startDate = ref<string>('')
const endDate = ref<string>('')
onMounted(() => {
loadPromoters()
})
async function loadPromoters() {
isLoading.value = true
try {
const params: PromoterListParams = {
search: searchQuery.value,
page: page.value,
pageSize: pageSize,
startTime: startDate.value ? (startDate.value + ' 00:00:00') : null,
endTime: endDate.value ? (endDate.value + ' 23:59:59') : null
}
const res = await getPromoterList(params)
promoterList.value = res
} catch (e) {
uni.showToast({ title: '加载推广员失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function onSearch() {
page.value = 1
loadPromoters()
}
function onApplyRange(payload: any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
page.value = 1
loadPromoters()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
page.value = 1
loadPromoters()
}
function onNextPage() {
if (promoterList.value.length < pageSize) return
page.value++
loadPromoters()
}
function onPrevPage() {
if (page.value <= 1) return
page.value--
loadPromoters()
}
function onExport() { uni.showToast({ title: '导出功能开发中', icon: 'none' }) }
function onPromoter(item : Promoter) { uni.showToast({ title: '推广人: ' + item.id, icon: 'none' }) }
function onMore(item : Promoter) { uni.showToast({ title: '更多: ' + item.id, icon: 'none' }) }
</script>
<style scoped lang="scss">
@@ -109,6 +186,26 @@ function onMore(item: any) { uni.showToast({ title: '更多: ' + item.id, icon:
.op-link { color: #1890ff; cursor: pointer; }
.op-divider { color: #e8e8e8; margin: 0 8px; }
.arrow-down { font-size: 10px; color: #1890ff; margin-left: 4px; }
.pagination { padding: 16px 24px; border-top: 1px solid #f0f0f0; }
.pagination { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.pager-btns { display: flex; flex-direction: row; align-items: center; gap: 12px; }
.page-num { font-size: 14px; color: #333; }
.page-info { font-size: 14px; color: #999; }
/* Loading & Empty Styles */
.loading-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-text { color: #1890ff; font-size: 14px; }
.empty-row {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
</style>

View File

@@ -301,10 +301,12 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { getDistributionConfig, saveDistributionConfig, DistributionConfig } from '@/services/admin/distributionService.uts'
const activeTab = ref(0)
const tabs = ['分销模式', '返佣设置', '提现设置']
const isLoading = ref(false)
const form = ref({
// 分销模式
@@ -338,6 +340,50 @@ const form = ref({
user_extract_fee: '0'
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
isLoading.value = true
try {
const res = await getDistributionConfig()
if (res != null) {
// 映射 DB 类型 (boolean/number) 到页面字符串类型 ('1'/'0')
form.value.statue = res.is_enabled ? '1' : '0'
form.value.extract_type = res.extract_type
form.value.bind_type = res.bind_type
form.value.store_brokerage_binding_status = res.store_brokerage_binding_status
form.value.brokerage_poster_status = res.brokerage_poster_status ?? ''
form.value.brokerage_level = String(res.brokerage_level)
form.value.is_area_manager = res.is_area_manager ? '1' : '0'
form.value.is_agent_apply = res.is_agent_apply ? '1' : '0'
form.value.is_commission_window = res.is_commission_window ? '1' : '0'
form.value.is_self_brokerage = res.is_self_brokerage ? '1' : '0'
form.value.is_member_brokerage = res.is_member_brokerage ? '1' : '0'
form.value.brokerage_type = res.brokerage_type
form.value.is_promoter_brokerage = res.is_promoter_brokerage ? '1' : '0'
form.value.promoter_brokerage_price = String(res.promoter_brokerage_price)
form.value.promoter_brokerage_day_max = String(res.promoter_brokerage_day_max)
form.value.store_brokerage_ratio = String(res.store_brokerage_ratio)
form.value.store_brokerage_two_ratio = String(res.store_brokerage_two_ratio)
form.value.extract_frozen_time = String(res.extract_frozen_time)
form.value.user_extract_min_price = String(res.user_extract_min_price)
form.value.extract_bank_list = res.extract_bank_list
form.value.extract_type_list = res.extract_type_list
form.value.wechat_extract_type = res.wechat_extract_type
form.value.alipay_extract_type = res.alipay_extract_type
form.value.user_extract_fee = String(res.user_extract_fee)
}
} catch (e) {
uni.showToast({ title: '加载配置失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function onUploadPoster() {
uni.chooseImage({
count: 1,
@@ -347,8 +393,50 @@ function onUploadPoster() {
})
}
function onSubmit() {
async function onSubmit() {
isLoading.value = true
try {
// 映射页面字符串类型到 DB 类型
const config: DistributionConfig = {
is_enabled: form.value.statue === '1',
extract_type: form.value.extract_type,
bind_type: form.value.bind_type,
store_brokerage_binding_status: form.value.store_brokerage_binding_status,
brokerage_poster_status: form.value.brokerage_poster_status,
brokerage_level: parseInt(form.value.brokerage_level),
is_area_manager: form.value.is_area_manager === '1',
is_agent_apply: form.value.is_agent_apply === '1',
is_commission_window: form.value.is_commission_window === '1',
is_self_brokerage: form.value.is_self_brokerage === '1',
is_member_brokerage: form.value.is_member_brokerage === '1',
brokerage_type: form.value.brokerage_type,
is_promoter_brokerage: form.value.is_promoter_brokerage === '1',
promoter_brokerage_price: parseFloat(form.value.promoter_brokerage_price),
promoter_brokerage_day_max: parseFloat(form.value.promoter_brokerage_day_max),
store_brokerage_ratio: parseFloat(form.value.store_brokerage_ratio),
store_brokerage_two_ratio: parseFloat(form.value.store_brokerage_two_ratio),
extract_frozen_time: parseInt(form.value.extract_frozen_time),
user_extract_min_price: parseFloat(form.value.user_extract_min_price),
extract_bank_list: form.value.extract_bank_list,
extract_type_list: form.value.extract_type_list,
wechat_extract_type: form.value.wechat_extract_type,
alipay_extract_type: form.value.alipay_extract_type,
user_extract_fee: parseFloat(form.value.user_extract_fee)
}
const success = await saveDistributionConfig(config)
if (success) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '操作异常', icon: 'none' })
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -157,6 +157,7 @@ import { ref, onMounted, reactive, computed } from 'vue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchFinanceOverview, fetchFinanceBillSummary } from '@/services/admin/financeService.uts'
import { fetchOrderStats } from '@/services/orderService.uts'
import AkReq from '@/uni_modules/ak-req/ak-req.uts'
const dateOptions = ['最近30天', '最近7天', '本月', '本年']
const activeDateTab = ref(0)
@@ -237,6 +238,9 @@ function handleDateTabChange(index : number) {
}
onMounted(() => {
if (AkReq.getToken() == null || AkReq.getToken() === '') {
return
}
loadData()
})

View File

@@ -68,26 +68,32 @@
</view>
<view class="table-body">
<view v-for="(item, index) in dataList" :key="item.id" class="table-row">
<view class="td" style="width: 80px;"><text class="td-txt">{{ item.id }}</text></view>
<view v-if="isLoading" class="table-loading" style="padding: 40px; text-align: center;">
<text>数据加载中...</text>
</view>
<view v-else-if="dataList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无优惠券数据</text>
</view>
<view v-else v-for="(item, index) in dataList" :key="item.id" class="table-row">
<view class="td" style="width: 80px;"><text class="td-txt">{{ item.cid || '-' }}</text></view>
<view class="td" style="width: 180px;"><text class="td-txt name-bold">{{ item.name }}</text></view>
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.type }}</text></view>
<view class="td" style="width: 100px;"><text class="td-txt price-txt">{{ item.value.toFixed(2) }}</text></view>
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.receiveType }}</text></view>
<view class="td" style="width: 150px;">
<text v-if="item.id === 1628" class="td-txt date-small">2023-10-18 00:00 - 2025-11-05 00:00</text>
<text v-else class="td-txt">{{ item.receiveDate }}</text>
<view class="td" style="width: 120px;"><text class="td-txt">{{ getTypeName(item.coupon_type) }}</text></view>
<view class="td" style="width: 100px;">
<text class="td-txt price-txt">{{ item.discount_value }}{{ item.discount_type === 2 ? '%' : '元' }}</text>
</view>
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.useTime }}</text></view>
<view class="td" style="width: 120px;"><text class="td-txt">{{ getReceiveTypeName(1) }}</text></view>
<view class="td" style="width: 150px;">
<text class="td-txt date-small">{{ formatTime(item.start_time) }} 至 {{ formatTime(item.end_time) }}</text>
</view>
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.usage_limit }} 次/人</text></view>
<view class="td" style="width: 120px;">
<view v-if="item.publishTotal > 0" class="pub-info">
<text class="pub-txt">发布: {{ item.publishTotal }}</text>
<text class="pub-txt danger">剩余: {{ item.publishRemain }}</text>
<view v-if="item.total_quantity != null && item.total_quantity > 0" class="pub-info">
<text class="pub-txt">总计: {{ item.total_quantity }}</text>
</view>
<text v-else class="td-txt">不限量</text>
</view>
<view class="td" style="width: 100px;">
<view :class="['switch-box', item.isOpen ? 'active' : '']" @click="toggleStatus(index)">
<view :class="['switch-box', item.status === 1 ? 'active' : '']" @click="toggleStatus(index)">
<view class="switch-dot"></view>
</view>
</view>
@@ -97,9 +103,7 @@
<text class="op-split">|</text>
<text class="op-link">编辑</text>
<text class="op-split">|</text>
<text class="op-link">复制</text>
<text class="op-split">|</text>
<text class="op-link text-danger">删除</text>
<text class="op-link" @click="handleDelete(item.id)">删除</text>
</view>
</view>
</view>
@@ -108,20 +112,11 @@
<!-- 分页 -->
<view class="pagination-footer">
<text class="total-txt">共 16 条</text>
<view class="page-select">
<text class="page-val">15条/页 ▼</text>
</view>
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text class="p-btn disabled"><</text>
<text class="p-btn active">1</text>
<text class="p-btn">2</text>
<text class="p-btn">></text>
</view>
<view class="page-jump">
<text class="jump-txt">前往</text>
<input class="jump-input" placeholder="1" />
<text class="jump-txt">页</text>
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"><</text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', page >= totalPages ? 'disabled' : '']" @click="nextPage">></text>
</view>
</view>
</view>
@@ -130,44 +125,145 @@
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
interface CouponItem {
id: number
name: string
type: string
value: number
receiveType: string
receiveDate: string
useTime: string
publishTotal: number
publishRemain: number
isOpen: boolean
}
import { ref, reactive, onMounted, computed } from 'vue'
import {
fetchAdminCoupons,
toggleCouponStatus,
deleteCouponTemplate,
CouponTemplate,
CouponQuery
} from '@/services/admin/marketingService.uts'
const filter = reactive({
name: ''
name: '',
type: null as number | null,
status: null as number | null
})
const dataList = ref<CouponItem[]>([
{ id: 1643, name: '满100减30', type: '通用券', value: 30.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '10天', publishTotal: 0, publishRemain: 0, isOpen: false },
{ id: 1642, name: '满10减7', type: '通用券', value: 7.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '10天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1641, name: '会员优惠券', type: '通用券', value: 200.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '200天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1640, name: '会员优惠券', type: '通用券', value: 29.90, receiveType: '用户领取', receiveDate: '不限时', useTime: '200天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1639, name: '会员优惠券', type: '通用券', value: 1.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '200天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1638, name: '商品券', type: '商品券', value: 1.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '200天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1636, name: '测试多个商品消耗一个券', type: '商品券', value: 500.00, receiveType: '系统赠送', receiveDate: '不限时', useTime: '3天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1635, name: '优惠券', type: '通用券', value: 10.00, receiveType: '系统赠送', receiveDate: '不限时', useTime: '10天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1634, name: '限时优惠', type: '通用券', value: 20.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '5天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1633, name: '店庆券', type: '品类券', value: 100.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '10天', publishTotal: 0, publishRemain: 0, isOpen: true },
{ id: 1632, name: '优惠券', type: '品类券', value: 99.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '10天', publishTotal: 8999, publishRemain: 8604, isOpen: true },
{ id: 1628, name: '全场通用券', type: '通用券', value: 9.90, receiveType: '用户领取', receiveDate: 'RANGE', useTime: '不限时', publishTotal: 59999, publishRemain: 59331, isOpen: true }
])
const dataList = ref<CouponTemplate[]>([])
const total = ref(0)
const isLoading = ref(false)
const page = ref(1)
const pageSize = 15
const handleQuery = () => { console.log('Querying...') }
const handleAdd = () => { console.log('Adding coupon...') }
const toggleStatus = (index: number) => {
dataList.value[index].isOpen = !dataList.value[index].isOpen
const typeOptions = [
{ label: '全部', value: null },
{ label: '满减券', value: 1 },
{ label: '折扣券', value: 2 },
{ label: '免运费券', value: 3 }
]
const typeIndex = ref(0)
const statusOptions = [
{ label: '全部', value: null },
{ label: '开启', value: 1 },
{ label: '关闭', value: 2 },
{ label: '已结束', value: 3 }
]
const statusIndex = ref(0)
const totalPages = computed((): number => {
return Math.ceil(total.value / pageSize) || 1
})
onMounted(() => {
loadData()
})
async function loadData() {
isLoading.value = true
try {
const query: CouponQuery = {
name: filter.name || null,
type: typeOptions[typeIndex.value].value,
status: statusOptions[statusIndex.value].value,
page: page.value,
pageSize: pageSize
}
const res = await fetchAdminCoupons(query)
dataList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function handleQuery() {
page.value = 1
loadData()
}
function handleAdd() {
uni.showToast({ title: '添加功能开发中', icon: 'none' })
}
async function toggleStatus(index: number) {
const item = dataList.value[index]
if (item.id == null) return
const currentStatus = item.status === 1
const success = await toggleCouponStatus(item.id!, !currentStatus)
if (success) {
uni.showToast({ title: '操作成功', icon: 'success' })
loadData()
}
}
async function handleDelete(id: string | undefined) {
if (id == null) return
uni.showModal({
title: '提示',
content: '确定删除该优惠券模板吗?',
success: async (res) => {
if (res.confirm) {
const success = await deleteCouponTemplate(id)
if (success) {
uni.showToast({ title: '删除成功', icon: 'success' })
loadData()
}
}
}
} as ShowModalOptions)
}
function onTypeChange(e: any) {
typeIndex.value = parseInt(String(e.detail.value))
handleQuery()
}
function onStatusFilterChange(e: any) {
statusIndex.value = parseInt(String(e.detail.value))
handleQuery()
}
function prevPage() {
if (page.value > 1) {
page.value--
loadData()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value++
loadData()
}
}
function getTypeName(type: number): string {
const found = typeOptions.find(o => o.value === type)
return found ? found.label : '未知'
}
function getReceiveTypeName(receiveType: number): string {
return receiveType === 1 ? '用户领取' : '系统赠送'
}
function formatTime(iso: string | null): string {
if (!iso) return '-'
return iso.substring(0, 16).replace('T', ' ')
}
</script>

View File

@@ -15,12 +15,12 @@
<view class="filter-item">
<text class="label-txt">退款时间:</text>
<view class="date-picker-mock">
<text class="date-txt">开始日期</text>
<text class="date-split">-</text>
<text class="date-txt">结束日期</text>
<text class="calendar-ic">📅</text>
</view>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
<view class="filter-item">
@@ -103,6 +103,7 @@
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchRefundOrderPage } from '@/services/orderService.uts'
interface RefundOrder {
@@ -127,6 +128,9 @@ const page = ref(1)
const pageSize = ref(15)
const jumpPage = ref('')
const startDate = ref('')
const endDate = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
const pages = Math.ceil(total.value / pageSize.value)
@@ -149,9 +153,24 @@ const onStatusChange = (e : any) => {
handleQuery()
}
function onApplyRange(payload : any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
handleQuery()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
handleQuery()
}
const loadRefundOrders = async () => {
loading.value = true
try {
const st = startDate.value ? (startDate.value + ' 00:00:00') : null
const et = endDate.value ? (endDate.value + ' 23:59:59') : null
const res = await fetchRefundOrderPage(
page.value,
pageSize.value,
@@ -159,7 +178,7 @@ const loadRefundOrders = async () => {
searchQuery.value
)
orderList.value = res.items.map((item: any): RefundOrder => {
orderList.value = res.items.map((item : any) : RefundOrder => {
const refundStatusCode = parseInt(String(item.refund_status ?? '0'))
const orderStatusCode = parseInt(String(item.order_status ?? '0'))

View File

@@ -5,12 +5,12 @@
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">创建时间:</text>
<view class="date-picker-mock">
<text class="date-txt">开始日期</text>
<text class="date-split">-</text>
<text class="date-txt">结束日期</text>
<text class="calendar-ic">📅</text>
</view>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
<view class="filter-item">
@@ -114,6 +114,7 @@
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchCashierOrderPage } from '@/services/orderService.uts'
interface CashierOrder {
@@ -133,19 +134,28 @@ const page = ref(1)
const pageSize = ref(15)
const jumpPage = ref('')
const startDate = ref('')
const endDate = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
const pages = Math.ceil(total.value / pageSize.value)
return pages <= 0 ? 1 : pages
})
const loadCashierOrders = async () => {
loading.value = true
try {
const st = startDate.value ? (startDate.value + ' 00:00:00') : null
const et = endDate.value ? (endDate.value + ' 23:59:59') : null
const res = await fetchCashierOrderPage(
page.value,
pageSize.value,
orderId.value ? orderId.value : null,
username.value ? username.value : null
username.value ? username.value : null,
st,
et
)
orderList.value = res.items.map((item: any): CashierOrder => {
@@ -174,6 +184,18 @@ const handleQuery = () => {
loadCashierOrders()
}
function onApplyRange(payload : any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
handleQuery()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
handleQuery()
}
const prevPage = () => {
if (page.value > 1) {
page.value--

View File

@@ -1,536 +1,42 @@
<template>
<view class="order-list-page">
<!-- 筛选区域 -->
<view class="filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">订单类型:</text>
<view class="mock-select">
<text>全部订单</text>
<view class="arrow-down"></view>
</view>
</view>
<view class="filter-item">
<text class="label">支付方式:</text>
<view class="mock-select">
<text>全部</text>
<view class="arrow-down"></view>
</view>
</view>
<view class="filter-item long">
<text class="label">创建时间:</text>
<view class="mock-date-range">
<image class="cal-icon" src="/static/icons/calendar.png" mode="aspectFit" />
<text class="placeholder">开始日期 - 结束日期</text>
</view>
</view>
<view class="filter-item search">
<text class="label">订单搜索:</text>
<view class="search-group">
<view class="search-select">
<text>全部</text>
<view class="arrow-down"></view>
</view>
<input class="search-input" placeholder="请输入" v-model="searchText" @confirm="handleQuery" />
</view>
</view>
</view>
<view class="btn-row">
<button class="btn btn-primary" @click="handleQuery">查询</button>
<button class="btn btn-default" @click="handleReset">重置</button>
</view>
</view>
<!-- 列表数据区域 -->
<view class="content-card">
<!-- 状态 Tabs -->
<view class="status-tabs">
<view
v-for="(tab, index) in statusTabs"
:key="index"
class="tab-item"
:class="{ active: activeTab === index }"
@click="handleTabChange(index)"
>
<text class="tab-text">{{ tab.name }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-bar">
<button class="action-btn btn-blue">订单核销</button>
<button class="action-btn btn-outline">批量发货</button>
<button class="action-btn btn-outline">批量删除</button>
<button class="action-btn btn-outline">订单导出</button>
</view>
<!-- 数据表格 -->
<view class="order-table">
<view class="thead">
<view class="th col-check">
<checkbox :checked="false" color="#1890ff" />
</view>
<view class="th col-order">订单号 | 类型</view>
<view class="th col-product">商品信息</view>
<view class="th col-user">用户信息</view>
<view class="th col-price">实际支付</view>
<view class="th col-pay">支付方式</view>
<view class="th col-time">支付时间</view>
<view class="th col-status">订单状态</view>
<view class="th col-op">操作</view>
</view>
<view class="tbody">
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="orderData.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无订单数据</text>
</view>
<view v-else v-for="(item, index) in orderData" :key="index" class="tr">
<view class="td col-check">
<checkbox :checked="false" color="#1890ff" />
</view>
<!-- 订单号|类型 -->
<view class="td col-order">
<text class="order-sn">{{ item.order_no }}</text>
<text class="order-type blue">[{{ getChannelName(item.channel_type) }}]</text>
</view>
<!-- 商品信息 -->
<view class="td col-product">
<view class="product-info-wrap">
<image class="p-img" :src="item.first_item_summary?.image_url" mode="aspectFill" />
<text class="p-name">{{ item.first_item_summary?.product_name || '多商品订单' }}</text>
</view>
</view>
<!-- 用户信息 -->
<view class="td col-user">
<text class="u-info">{{ item.buyer_name }} | {{ item.buyer_phone }}</text>
</view>
<!-- 实际支付 -->
<view class="td col-price">
<text class="price-val">¥{{ item.total_amount }}</text>
</view>
<!-- 支付方式 -->
<view class="td col-pay">
<text class="pay-text">{{ getPayTypeName(item.pay_type) }}</text>
</view>
<!-- 支付时间 -->
<view class="td col-time">
<text class="time-text">{{ item.paid_at || '-' }}</text>
</view>
<!-- 订单状态 -->
<view class="td col-status">
<text class="status-text">{{ getStatusName(item.order_status) }}</text>
</view>
<!-- 操作 -->
<view class="td col-op">
<view class="op-links">
<text class="op-link primary">详情</text>
<view class="op-link-more">
<text class="more-text">更多</text>
<view class="arrow-down-blue"></view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 分页区域 -->
<view class="pagination-row" style="padding: 16px 20px;">
<text class="total-text">共 {{ total }} 条</text>
<view class="page-btns">
<view class="page-btn" :class="{ disabled: page <= 1 }" @click="prevPage"><text></text></view>
<view class="page-btn active"><text>{{ page }}</text></view>
<view class="page-btn" :class="{ disabled: page >= totalPages }" @click="nextPage"><text></text></view>
</view>
<view class="page-jump">
<text>前往</text>
<input class="jump-input" v-model="jumpPage" type="number" @confirm="goToJumpPage" />
<text>页</text>
</view>
<view class="page-container">
<view class="page-content">
<view class="placeholder-card">
<text class="placeholder-title">正在跳转...</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { fetchOrderPage } from '@/services/orderService.uts'
const activeTab = ref(0)
const searchText = ref('')
const total = ref(0)
const orderData = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(15)
const jumpPage = ref('')
const statusTabs = [
{ name: '全部', value: null as number | null },
{ name: '待付款', value: 1 as number | null },
{ name: '待发货', value: 2 as number | null },
{ name: '待收货', value: 3 as number | null },
{ name: '已完成', value: 4 as number | null },
{ name: '已取消', value: 5 as number | null },
{ name: '退款中', value: 6 as number | null },
{ name: '已退款', value: 7 as number | null }
]
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
const loadOrders = async () => {
loading.value = true
try {
const res = await fetchOrderPage(
page.value,
pageSize.value,
statusTabs[activeTab.value].value,
searchText.value || null
)
orderData.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载订单失败', icon: 'none' })
} finally {
loading.value = false
}
}
import { onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
onMounted(() => {
loadOrders()
// 跳转到最新的订单管理页面
openRoute('OrderList')
})
const handleQuery = () => {
page.value = 1
loadOrders()
}
const handleReset = () => {
searchText.value = ''
page.value = 1
loadOrders()
}
const handleTabChange = (index: number) => {
activeTab.value = index
page.value = 1
loadOrders()
}
const prevPage = () => {
if (page.value > 1) {
page.value--
loadOrders()
}
}
const nextPage = () => {
if (page.value < totalPages.value) {
page.value++
loadOrders()
}
}
const goToJumpPage = () => {
const targetPage = parseInt(jumpPage.value)
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages.value) {
page.value = targetPage
loadOrders()
jumpPage.value = ''
} else {
uni.showToast({ title: '页码无效', icon: 'none' })
}
}
function getStatusName(status: number): string {
const tab = statusTabs.find(t => t.value === status)
return tab?.name ?? '未知'
}
function getPayTypeName(type: number | null): string {
switch (type) {
case 1: return '余额支付'
case 2: return '微信支付'
case 3: return '支付宝'
case 4: return '线下支付'
default: return '其他'
}
}
function getChannelName(type: number | null): string {
switch (type) {
case 1: return '公众号'
case 2: return '小程序'
case 3: return 'H5'
case 4: return 'PC'
case 5: return 'APP'
default: return '普通订单'
}
}
</script>
<style scoped lang="scss">
.order-list-page {
padding: 16px;
background-color: #f0f2f5;
.page-container {
padding: 20px;
min-height: 100vh;
background: #f5f5f5;
}
.filter-card {
background-color: #fff;
.page-content {
background: #fff;
border-radius: 4px;
padding: 24px;
border-radius: 4px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 24px;
.placeholder-card {
text-align: center;
padding: 60px 20px;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
.label {
font-size: 14px;
color: #333;
width: 70px;
}
}
.mock-select {
width: 160px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
text { font-size: 14px; color: #595959; }
}
.mock-date-range {
width: 240px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
.cal-icon { width: 14px; height: 14px; margin-right: 8px; opacity: 0.4; }
.placeholder { font-size: 14px; color: #bfbfbf; }
}
.search-group {
display: flex;
flex-direction: row;
border: 1px solid #d9d9d9;
border-radius: 4px;
height: 32px;
overflow: hidden;
}
.search-select {
width: 80px;
border-right: 1px solid #d9d9d9;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
background-color: #fafafa;
text { font-size: 14px; color: #595959; }
}
.search-input {
flex: 1;
border: none;
padding: 0 12px;
font-size: 14px;
width: 120px;
}
.arrow-down {
width: 0; height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #bfbfbf;
}
.btn-row {
margin-top: 16px;
display: flex;
flex-direction: row;
gap: 8px;
}
.btn {
height: 32px;
padding: 0 16px;
font-size: 14px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
.btn-primary { background-color: #1890ff; color: #fff; border: none; }
.btn-default { background-color: #fff; color: #595959; border: 1px solid #d9d9d9; }
.content-card {
background-color: #fff;
border-radius: 4px;
}
.status-tabs {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
padding: 0 16px;
}
.tab-item {
padding: 16px 20px;
cursor: pointer;
position: relative;
display: flex;
flex-direction: row;
gap: 2px;
.tab-text { font-size: 14px; color: #595959; }
.tab-count { font-size: 14px; color: #595959; }
&.active {
.tab-text, .tab-count { color: #1890ff; font-weight: 500; }
&::after {
content: '';
position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: #1890ff;
}
}
}
.action-bar {
padding: 16px 20px;
display: flex;
flex-direction: row;
gap: 12px;
}
.action-btn {
height: 32px;
padding: 0 16px;
font-size: 14px;
border-radius: 4px;
margin: 0;
display: flex;
align-items: center;
}
.btn-blue { background-color: #1890ff; color: #fff; border: none; }
.btn-outline { background-color: #fff; color: #595959; border: 1px solid #d9d9d9; }
/* 表格样式 */
.order-table {
width: 100%;
}
.thead {
display: flex;
flex-direction: row;
background-color: #f0f7ff;
}
.th {
padding: 12px 8px;
font-size: 14px;
color: #595959;
font-weight: 500;
display: flex;
align-items: center;
}
.tr {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
&:hover { background-color: #fafafa; }
}
.td {
padding: 16px 8px;
display: flex;
flex-direction: column;
justify-content: center;
}
/* 列宽控制 */
.col-check { width: 50px; justify-content: center; align-items: center; }
.col-order { width: 220px; }
.col-product { flex: 1; }
.col-user { width: 160px; }
.col-price { width: 100px; }
.col-pay { width: 100px; }
.col-time { width: 160px; }
.col-status { width: 100px; }
.col-op { width: 120px; }
/* 单元格具体内容样式 */
.order-sn { font-size: 13px; color: #262626; margin-bottom: 4px; }
.order-type { font-size: 12px; }
.blue { color: #1890ff; }
.purple { color: #722ed1; }
.green { color: #52c41a; }
.cancel-text { font-size: 12px; color: #ff4d4f; margin-top: 4px; }
.product-info-wrap {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
}
.p-img { width: 44px; height: 44px; border-radius: 4px; background-color: #f5f5f5; flex-shrink: 0; }
.p-name { font-size: 13px; color: #595959; line-height: 1.5; }
.u-info { font-size: 13px; color: #595959; }
.price-val { font-size: 14px; color: #262626; }
.pay-text, .time-text, .status-text { font-size: 13px; color: #595959; }
.op-links {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.op-link { font-size: 13px; cursor: pointer; }
.primary { color: #1890ff; }
.op-link-more {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.more-text { font-size: 13px; color: #1890ff; }
.arrow-down-blue {
width: 0; height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-top: 4px solid #1890ff;
.placeholder-title {
display: block;
font-size: 18px;
font-weight: 600;
color: #666;
}
</style>

View File

@@ -1,25 +1,414 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
<view class="order-management-page">
<!-- 1. 顶部筛选卡片 -->
<view class="filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">订单号:</text>
<input class="filter-input" v-model="searchOrderNo" placeholder="请输入订单号" @confirm="onSearch" />
</view>
<view class="filter-item">
<text class="label">用户信息:</text>
<input class="filter-input" v-model="searchUser" placeholder="姓名/手机号" @confirm="onSearch" />
</view>
<view class="filter-item">
<text class="label">下单时间:</text>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
<view class="filter-btns">
<button class="btn primary" @click="onSearch">查询</button>
<button class="btn reset" @click="onReset">重置</button>
</view>
</view>
</view>
<!-- 2. 状态选项卡 -->
<view class="status-tabs-card">
<view class="status-tabs">
<view
v-for="tab in statusTabs"
:key="tab.value"
class="tab-item"
:class="{ active: activeStatus === tab.value }"
@click="onStatusChange(tab.value)"
>
<text class="tab-text">{{ tab.label }}</text>
</view>
</view>
</view>
<!-- 3. 订单列表表格 -->
<view class="content-card">
<view class="table-container">
<!-- Loading 遮罩 -->
<view v-if="isLoading" class="loading-mask">
<text class="loading-text">订单数据加载中...</text>
</view>
<view class="table-header">
<view class="col col-no"><text>订单号</text></view>
<view class="col col-user"><text>用户信息</text></view>
<view class="col col-amount"><text>支付金额</text></view>
<view class="col col-status"><text>订单状态</text></view>
<view class="col col-time"><text>下单时间</text></view>
<view class="col col-ops"><text>操作</text></view>
</view>
<view class="table-body">
<view v-if="orderList.length === 0 && !isLoading" class="empty-row">
<text>未找到相关订单</text>
</view>
<view v-for="item in orderList" :key="item.id" class="table-row">
<view class="col col-no">
<text class="order-no">{{ item.order_no }}</text>
</view>
<view class="col col-user">
<view class="user-info-box">
<text class="u-name">{{ item.username || '匿名用户' }}</text>
<text class="u-phone">{{ item.phone || '-' }}</text>
</view>
</view>
<view class="col col-amount">
<text class="amount-val">¥{{ item.paid_amount?.toFixed(2) || '0.00' }}</text>
</view>
<view class="col col-status">
<text class="status-tag" :class="getStatusClass(item.order_status)">
{{ getStatusLabel(item.order_status) }}
</text>
</view>
<view class="col col-time">
<text class="time-text">{{ formatDateTime(item.created_at) }}</text>
</view>
<view class="col col-ops">
<text class="op-link" @click="goDetail(item.id)">详情</text>
<text class="op-divider">|</text>
<text class="op-link">备注</text>
</view>
</view>
</view>
</view>
<!-- 4. 分页控制 -->
<view class="pagination">
<view class="pager-btns">
<button class="btn small" :disabled="page <= 1" @click="onPrevPage">上一页</button>
<text class="page-num">第 {{ page }} 页</text>
<button class="btn small" :disabled="orderList.length < pageSize" @click="onNextPage">下一页</button>
</view>
<text class="page-info">当前页 {{ orderList.length }} 条 / 总计 {{ total }} 条</text>
</view>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('order-list')
const title = ref<string>('index')
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchOrderPage } from '@/services/orderService.uts'
import AkReq from '@/uni_modules/ak-req/ak-req.uts'
const currentPage = ref<string>('OrderList')
// --- 数据状态 ---
const orderList = ref<any[]>([])
const total = ref(0)
const isLoading = ref(false)
// --- 筛选条件 ---
const searchOrderNo = ref('')
const searchUser = ref('')
const activeStatus = ref<number | null>(null)
const startDate = ref('')
const endDate = ref('')
const page = ref(1)
const pageSize = 20
const statusTabs = [
{ label: '全部', value: null },
{ label: '待付款', value: 1 },
{ label: '待发货', value: 2 },
{ label: '待收货', value: 3 },
{ label: '已完成', value: 4 },
{ label: '已取消', value: 5 },
{ label: '退款中', value: 6 },
{ label: '已退款', value: 7 }
]
onMounted(() => {
if (AkReq.getToken() == null || AkReq.getToken() === '') return
loadOrders()
})
async function loadOrders() {
isLoading.value = true
try {
const st = startDate.value ? (startDate.value + ' 00:00:00') : null
const et = endDate.value ? (endDate.value + ' 23:59:59') : null
// 合并搜索词
const combinedSearch = (searchOrderNo.value || searchUser.value)
? `${searchOrderNo.value} ${searchUser.value}`.trim()
: null
const res = await fetchOrderPage(
page.value,
pageSize,
activeStatus.value,
combinedSearch,
st,
et
)
orderList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载订单失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
// --- 事件处理 ---
function onSearch() {
page.value = 1
loadOrders()
}
function onReset() {
searchOrderNo.value = ''
searchUser.value = ''
activeStatus.value = null
startDate.value = ''
endDate.value = ''
page.value = 1
loadOrders()
}
function onStatusChange(status: number | null) {
activeStatus.value = status
page.value = 1
loadOrders()
}
function onApplyRange(payload: any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
page.value = 1
loadOrders()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
page.value = 1
loadOrders()
}
function onPrevPage() {
if (page.value <= 1) return
page.value--
loadOrders()
}
function onNextPage() {
if (orderList.value.length < pageSize) return
page.value++
loadOrders()
}
// --- 辅助方法 ---
function getStatusLabel(status: number): string {
const found = statusTabs.find(t => t.value === status)
return found ? found.label : '未知'
}
function getStatusClass(status: number): string {
switch (status) {
case 1: return 'status-pending'
case 2: return 'status-shipping'
case 3: return 'status-delivery'
case 4: return 'status-completed'
case 5: return 'status-cancelled'
case 6: case 7: return 'status-refund'
default: return ''
}
}
function formatDateTime(iso: string | null): string {
if (!iso) return '-'
return iso.replace('T', ' ').split('.')[0]
}
function goDetail(id: string) {
uni.showToast({ title: '详情页开发中', icon: 'none' })
}
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
.order-management-page {
padding: 20px;
background-color: #f5f7f9;
min-height: 100vh;
}
.filter-card {
background: #fff;
padding: 24px;
border-radius: 4px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
.label { font-size: 14px; color: #333; margin-right: 8px; }
}
.filter-input {
border: 1px solid #d9d9d9;
border-radius: 4px;
height: 32px;
width: 180px;
padding: 0 12px;
font-size: 14px;
}
.filter-btns {
display: flex;
flex-direction: row;
gap: 12px;
}
.status-tabs-card {
background: #fff;
margin-bottom: 16px;
border-radius: 4px;
}
.status-tabs {
display: flex;
flex-direction: row;
padding: 0 24px;
}
.tab-item {
padding: 16px 20px;
cursor: pointer;
position: relative;
.tab-text { font-size: 14px; color: #666; }
&.active {
.tab-text { color: #1890ff; font-weight: bold; }
&::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 2px; background: #1890ff;
}
}
}
.content-card {
background: #fff;
border-radius: 4px;
position: relative;
}
.table-container {
padding: 24px;
min-height: 400px;
position: relative;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8faff;
border-bottom: 1px solid #f0f0f0;
padding: 12px 0;
}
.table-row {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
padding: 16px 0;
align-items: center;
&:hover { background: #fafafa; }
}
.col { padding: 0 12px; font-size: 14px; color: #333; display: flex; align-items: center; }
.col-no { width: 200px; }
.col-user { flex: 1; }
.col-amount { width: 120px; justify-content: center; }
.col-status { width: 120px; justify-content: center; }
.col-time { width: 180px; justify-content: center; }
.col-ops { width: 150px; justify-content: flex-end; }
.order-no { font-family: monospace; color: #1890ff; }
.user-info-box { display: flex; flex-direction: column; gap: 4px; }
.u-name { font-weight: 500; }
.u-phone { font-size: 12px; color: #999; }
.amount-val { color: #f5222d; font-weight: bold; }
.status-tag {
padding: 2px 10px; border-radius: 4px; font-size: 12px;
&.status-pending { background: #fff7e6; color: #faad14; border: 1px solid #ffe58f; }
&.status-shipping { background: #e6f7ff; color: #1890ff; border: 1px solid #bae7ff; }
&.status-completed { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
&.status-cancelled { background: #f5f5f5; color: #999; border: 1px solid #d9d9d9; }
&.status-refund { background: #fff1f0; color: #f5222d; border: 1px solid #ffa39e; }
}
.op-link { color: #1890ff; cursor: pointer; }
.op-divider { margin: 0 8px; color: #eee; }
.pagination {
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.pager-btns { display: flex; flex-direction: row; align-items: center; gap: 12px; }
.page-num { font-size: 14px; color: #333; }
.page-info { font-size: 14px; color: #999; }
.loading-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255, 255, 255, 0.7);
display: flex; align-items: center; justify-content: center;
z-index: 10;
}
.empty-row { padding: 60px 0; text-align: center; color: #999; }
.btn {
height: 32px; padding: 0 16px; font-size: 14px; border-radius: 4px; cursor: pointer;
&.primary { background: #1890ff; color: #fff; border: none; }
&.reset { background: #fff; color: #666; border: 1px solid #d9d9d9; }
&.small { height: 28px; padding: 0 12px; font-size: 13px; }
&[disabled] { opacity: 0.5; cursor: not-allowed; }
}
</style>

View File

@@ -5,10 +5,12 @@
<view class="filter-card">
<view class="filter-item">
<text class="filter-label">时间选择:</text>
<view class="date-picker-mock">
<image class="calendar-icon" src="/static/icons/calendar.png" mode="aspectFit" />
<text class="date-range">2026/01/04 - 2026/02/02</text>
</view>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
</view>
@@ -123,7 +125,9 @@
import { ref, onMounted } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchOrderStats, fetchOrderTrend, fetchOrderSourceStats, fetchOrderTypeStats } from '@/services/orderService.uts'
import AkReq from '@/uni_modules/ak-req/ak-req.uts'
const currentPage = ref<string>('order_statistic')
@@ -132,32 +136,57 @@ const trendOption = ref<any>({})
const sourceOption = ref<any>({})
const orderStats = ref<any>(null)
const startDate = ref<string>('')
const endDate = ref<string>('')
const orderTypeData = ref([
{ name: '普通订单', amount: '0.00', rate: '0.00' }
])
onMounted(() => {
if (AkReq.getToken() == null || AkReq.getToken() === '') {
return
}
// 默认最近 30 天
const end = new Date()
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
startDate.value = start.toISOString().substring(0, 10)
endDate.value = end.toISOString().substring(0, 10)
loadAllData()
})
function onApplyRange(payload: any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
loadAllData()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
loadAllData()
}
async function loadAllData() {
const endTime = new Date().toISOString()
const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const st = startDate.value ? (startDate.value + ' 00:00:00') : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const et = endDate.value ? (endDate.value + ' 23:59:59') : new Date().toISOString()
try {
// 1. 加载汇总数据
orderStats.value = await fetchOrderStats(startTime, endTime)
orderStats.value = await fetchOrderStats(st, et)
// 2. 加载趋势数据
const trendData = await fetchOrderTrend(startTime, endTime)
const trendData = await fetchOrderTrend(st, et)
initTrendChart(trendData)
// 3. 加载来源数据
const sourceData = await fetchOrderSourceStats(startTime, endTime)
const sourceData = await fetchOrderSourceStats(st, et)
initSourceChart(sourceData)
// 4. 加载订单类型数据
const typeData = await fetchOrderTypeStats(startTime, endTime)
const typeData = await fetchOrderTypeStats(st, et)
orderTypeData.value = typeData
} catch (e) {
uni.showToast({ title: '加载统计数据失败', icon: 'none' })

View File

@@ -5,12 +5,12 @@
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">核销日期:</text>
<view class="date-picker-mock">
<text class="date-txt">开始日期</text>
<text class="date-split">-</text>
<text class="date-txt">结束日期</text>
<text class="calendar-ic">📅</text>
</view>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
<view class="filter-item">
@@ -104,6 +104,7 @@
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchWriteOffRecordPage } from '@/services/orderService.uts'
interface WriteOffRecord {
@@ -127,6 +128,9 @@ const page = ref(1)
const pageSize = ref(15)
const jumpPage = ref('')
const startDate = ref('')
const endDate = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
@@ -135,10 +139,15 @@ const totalPages = computed((): number => {
const loadRecords = async () => {
loading.value = true
try {
const st = startDate.value ? (startDate.value + ' 00:00:00') : null
const et = endDate.value ? (endDate.value + ' 23:59:59') : null
const res = await fetchWriteOffRecordPage(
page.value,
pageSize.value,
searchQuery.value || null
searchQuery.value || null,
st,
et
)
recordList.value = res.items.map((item: any): WriteOffRecord => {
@@ -172,6 +181,18 @@ const handleQuery = () => {
loadRecords()
}
function onApplyRange(payload : any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
handleQuery()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
handleQuery()
}
const prevPage = () => {
if (page.value > 1) {
page.value--

View File

@@ -118,73 +118,169 @@ class="group-item"
</template>
<script setup lang="uts">
import { ref, reactive, computed } from 'vue'
interface Label {
id: number;
name: string;
groupId: number;
status: boolean;
showInMobile: boolean;
}
interface Group {
id: number;
name: string;
}
const groups = reactive<Group[]>([
{ id: 0, name: '全部' } as Group,
{ id: 1, name: '商务礼品专题' } as Group,
{ id: 2, name: '员工福利' } as Group,
{ id: 3, name: '主题' } as Group
])
const labels = reactive<Label[]>([
{ id: 1, name: '外事礼品', groupId: 1, status: true, showInMobile: true } as Label,
{ id: 2, name: '会议庆典', groupId: 1, status: true, showInMobile: true } as Label,
{ id: 3, name: '入职纪念', groupId: 2, status: true, showInMobile: true } as Label,
{ id: 4, name: '员工激励', groupId: 2, status: true, showInMobile: true } as Label,
{ id: 5, name: '员工生日', groupId: 2, status: true, showInMobile: true } as Label,
{ id: 6, name: '三八妇女节', groupId: 3, status: true, showInMobile: true } as Label,
{ id: 7, name: '新春快乐', groupId: 3, status: true, showInMobile: true } as Label
])
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 filteredLabels = computed((): Label[] => {
const activeGroup = groups[activeGroupIndex.value]
if (activeGroupIndex.value === 0) return labels
return labels.filter((l: Label): boolean => l.groupId === activeGroup.id)
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: Label | null = null) {
if (label) { labelForm.name = label.name } else { labelForm.name = '' }
showDrawerMask.value = true
setTimeout(() => {
showDrawer.value = true
}, 50)
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)
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.showToast({ title: '添加分组功能已模拟', icon: 'none' })
}
function deleteLabel(label: Label) {
const idx = labels.indexOf(label)
if (idx > -1) { labels.splice(idx, 1) }
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>

View File

@@ -1,25 +1,20 @@
<template>
<view class="page-container">
<view class="page-header">
<text class="page-title">商品管理</text>
<text class="page-subtitle">Component: ProductList</text>
</view>
<view class="page-content">
<view class="placeholder-card">
<text class="placeholder-title">页面占位</text>
<text class="placeholder-desc">该功能模块正在开发中</text>
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text>
<text class="placeholder-title">正在跳转...</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
// TODO: 实现 商品管理 的具体功能
const loading = ref<boolean>(false)
onMounted(() => {
openRoute('product_productList')
})
</script>
<style scoped lang="scss">

View File

@@ -5,16 +5,16 @@
<view class="search-row">
<view class="search-item">
<text class="search-label">模板搜索:</text>
<input class="search-input" placeholder="请输入模板名称" />
<input class="search-input" v-model="searchName" placeholder="请输入模板名称" @confirm="handleSearch" />
</view>
<button class="btn-query">查询</button>
<button class="btn-query" @click="handleSearch">查询</button>
</view>
</view>
<!-- 数据表格区域 -->
<view class="table-card">
<view class="table-toolbar">
<button class="btn-add" @click="openDrawer()">添加商品参数</button>
<button class="btn-add" @click="openDrawer(null)">添加商品参数</button>
</view>
<view class="table-header">
@@ -37,7 +37,7 @@
<view class="td-cell flex-2 row-center">
<text class="btn-link" @click="openDrawer(item)">编辑</text>
<view class="divider"></view>
<text class="btn-link delete" @click="deleteItem(index)">删除</text>
<text class="btn-link delete" @click="deleteItem(item)">删除</text>
</view>
</view>
</view>
@@ -101,107 +101,132 @@
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { fetchParamTemplates, saveParamTemplate, deleteParamTemplate, ProductParamKV, ProductParamTemplate } from '@/services/admin/productSpecParamService.uts'
interface ParamKV {
label: string;
value: string;
}
type ParamItem = ProductParamTemplate
interface ParamItem {
id: number;
name: string;
sort: number;
params: ParamKV[];
}
const list = reactive<ParamItem[]>([
{ id: 1, name: '手机数码', sort: 1, params: [{label: '品牌', value: '华为'}, {label: '型号', value: 'Mate 60'}] as ParamKV[] },
{ id: 2, name: '家用电器', sort: 2, params: [{label: '能效等级', value: '一级'}, {label: '产地', value: '中国'}] as ParamKV[] }
])
const list = ref<ParamItem[]>([])
const isLoading = ref(false)
const searchName = ref('')
const showDrawerMask = ref(false)
const showDrawer = ref(false)
const isEdit = ref(false)
const editIndex = ref(-1)
const editingId = ref<string | null>(null)
const form = reactive({
name: '',
sort: 0,
params: [] as ParamKV[]
name: '',
sort: 0,
params: [] as ProductParamKV[]
})
function formatParams(params: ParamKV[]): string {
return params.map(p => p.label + ':' + p.value).join(' | ')
onMounted(() => {
loadData()
})
async function loadData() {
isLoading.value = true
try {
const res = await fetchParamTemplates(searchName.value)
list.value = res
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function handleSearch() {
loadData()
}
function formatParams(params: ProductParamKV[]): string {
return params.map(p => p.label + ':' + p.value).join(' | ')
}
function openDrawer(item: ParamItem | null = null) {
if (item != null) {
isEdit.value = true
form.name = item.name
form.sort = item.sort
form.params = JSON.parse<ParamKV[]>(JSON.stringify(item.params)) as ParamKV[]
editIndex.value = list.indexOf(item)
} else {
isEdit.value = false
form.name = ''
form.sort = 0
form.params = [{ label: '', value: '' }] as ParamKV[]
}
if (item != null) {
isEdit.value = true
editingId.value = item.id ?? null
form.name = item.name
form.sort = item.sort_order
form.params = JSON.parse<ProductParamKV[]>(JSON.stringify(item.params)) as ProductParamKV[]
} else {
isEdit.value = false
editingId.value = null
form.name = ''
form.sort = 0
form.params = [{ label: '', value: '' }] as ProductParamKV[]
}
showDrawerMask.value = true
setTimeout(() => {
showDrawer.value = true
}, 50)
showDrawerMask.value = true
setTimeout(() => {
showDrawer.value = true
}, 50)
}
function closeDrawer() {
showDrawer.value = false
setTimeout(() => {
showDrawerMask.value = false
}, 300)
showDrawer.value = false
setTimeout(() => {
showDrawerMask.value = false
}, 300)
}
function addParamRow() {
form.params.push({ label: '', value: '' } as ParamKV)
form.params.push({ label: '', value: '' } as ProductParamKV)
}
function removeParamRow(index: number) {
form.params.splice(index, 1)
form.params.splice(index, 1)
}
function saveParam() {
if (!form.name) {
uni.showToast({ title: '请输入模板名称', icon: 'none' })
return
}
if (isEdit.value) {
const item = list[editIndex.value]
item.name = form.name
item.sort = form.sort
item.params = JSON.parse<ParamKV[]>(JSON.stringify(form.params)) as ParamKV[]
} else {
list.unshift({
id: Date.now() % 1000,
name: form.name,
sort: form.sort,
params: JSON.parse<ParamKV[]>(JSON.stringify(form.params)) as ParamKV[]
} as ParamItem)
}
closeDrawer()
uni.showToast({ title: '保存成功', icon: 'success' })
async function saveParam() {
if (!form.name) {
uni.showToast({ title: '请输入模板名称', icon: 'none' })
return
}
isLoading.value = true
const tpl: ProductParamTemplate = {
id: editingId.value ?? undefined,
name: form.name,
sort_order: form.sort,
params: JSON.parse<ProductParamKV[]>(JSON.stringify(form.params)) as ProductParamKV[],
is_active: true
}
try {
const success = await saveParamTemplate(tpl)
if (success) {
uni.showToast({ title: '保存成功', icon: 'success' })
closeDrawer()
loadData()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '操作异常', icon: 'none' })
} finally {
isLoading.value = false
}
}
function deleteItem(index: number) {
uni.showModal({
title: '提示',
content: '确定删除该参数模板吗?',
success: (res) => {
if (res.confirm) {
list.splice(index, 1)
}
}
})
function deleteItem(item: ParamItem) {
if (item.id == null) return
uni.showModal({
title: '提示',
content: '确定删除该参数模板吗?',
success: async (res) => {
if (res.confirm) {
const success = await deleteParamTemplate(item.id!)
if (success) {
uni.showToast({ title: '删除成功', icon: 'success' })
loadData()
}
}
}
})
}
</script>

View File

@@ -20,129 +20,480 @@
<!-- 表单内容 -->
<view class="form-card">
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品类型:</text></view>
<view class="input-wrap">
<view class="radio-group">
<view class="radio-item active">
<text class="radio-circle on"></text>
<view class="radio-txt">
<text class="main">普通商品</text>
<text class="sub">(物流发货)</text>
<!-- Step 0: 基础信息 -->
<view v-if="activeStep === 0" class="step-content">
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品类型:</text></view>
<view class="input-wrap">
<view class="radio-group">
<view class="radio-item active">
<text class="radio-circle on"></text>
<view class="radio-txt">
<text class="main">普通商品</text>
<text class="sub">(物流发货)</text>
</view>
</view>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品名称:</text></view>
<view class="input-wrap">
<view class="input-box">
<input class="real-input" v-model="form.name" maxlength="80" placeholder="请输入商品名称" />
<text class="count">{{ form.name.length }}/80</text>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>单位:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" v-model="form.unit" maxlength="5" placeholder="件" />
<text class="count">{{ form.unit.length }}/5</text>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品轮播图:</text></view>
<view class="input-wrap">
<view class="image-uploader">
<view v-for="(img, i) in form.image_urls" :key="i" class="img-item">
<image :src="img" mode="aspectFill" />
<view class="img-close" @click="removeImage(i)">×</view>
</view>
<view v-if="form.image_urls.length < 10" class="upload-btn" @click="onUploadCarousel">
<text class="icon">+</text>
</view>
</view>
<text class="tip">建议尺寸800*800默认首张图为主图最多上传10张</text>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品分类:</text></view>
<view class="input-wrap">
<picker :value="categoryIndex" :range="categoryLabels" @change="onCategoryChange">
<view class="tag-selector">
<view v-if="selectedCategoryName" class="tag-item">
<text>{{ selectedCategoryName }}</text>
</view>
<text class="add-link">{{ selectedCategoryName ? '点击切换分类' : '选择分类' }}</text>
</view>
</picker>
</view>
</view>
<view class="form-item">
<view class="label"><text>商品标签:</text></view>
<view class="input-wrap">
<view class="tag-selector">
<view v-for="(tag, tidx) in form.tags" :key="tidx" class="tag-item">
<text>{{ tag }}</text>
<text class="close" @click="removeTag(tag)">×</text>
</view>
<text class="add-link" @click="showLabelModal = true">选择标签</text>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text>商品状态:</text></view>
<view class="input-wrap">
<view class="radio-group-simple">
<view class="radio-simple" :class="{ on: form.status === 1 }" @click="form.status = 1">
<text class="dot"></text>
<text>上架</text>
</view>
<view class="radio-simple" :class="{ on: form.status === 2 }" @click="form.status = 2">
<text class="dot"></text>
<text>下架</text>
</view>
</view>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品名称:</text></view>
<view class="input-wrap">
<view class="input-box">
<input class="real-input" value="UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060" />
<text class="count">36/80</text>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>单位:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" value="件" />
<text class="count">1/5</text>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品轮播图:</text></view>
<view class="input-wrap">
<view class="image-uploader">
<view v-for="(img, i) in carouselImages" :key="i" class="img-item">
<image :src="img" mode="aspectFill" />
<view class="img-close">×</view>
</view>
<view class="upload-btn">
<text class="icon">+</text>
<!-- Step 1: 规格库存 -->
<view v-if="activeStep === 1" class="step-content">
<view class="form-item">
<view class="label"><text>基础售价:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" type="digit" v-model="form.price" placeholder="0.00" />
</view>
</view>
<text class="tip">建议尺寸800*800可拖拽改变图片顺序默认首张图为主图最多上传10张</text>
</view>
</view>
<view class="form-item">
<view class="label"><text>添加视频:</text></view>
<view class="input-wrap">
<view class="upload-btn v-btn">
<text class="v-icon">📹</text>
</view>
<text class="tip">建议时长9~30秒视频宽高比16:9</text>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品分类:</text></view>
<view class="input-wrap">
<view class="tag-selector">
<view v-for="tag in categories" :key="tag" class="tag-item">
<text>{{ tag }}</text>
<text class="close">×</text>
<view class="form-item">
<view class="label"><text>库存总数:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" type="number" v-model="form.stock" placeholder="0" />
</view>
<text class="add-link">新增分类</text>
</view>
</view>
<view class="form-item">
<view class="label"><text>规格模板:</text></view>
<view class="input-wrap">
<picker :range="specTemplateLabels" @change="onSpecTemplateChange">
<button class="btn-white small-btn">快速导入模板</button>
</picker>
</view>
</view>
<!-- SKU 表格区域 -->
<view class="sku-table-wrap">
<view class="sku-table-header">
<text class="sku-th flex-2">规格</text>
<text class="sku-th flex-1">价格</text>
<text class="sku-th flex-1">库存</text>
<text class="sku-th flex-1">编号</text>
</view>
<view v-for="(sku, idx) in skus" :key="idx" class="sku-tr">
<text class="sku-td flex-2">{{ formatSpecs(sku.specifications) }}</text>
<view class="sku-td flex-1">
<input class="sku-input" type="digit" v-model="sku.price" />
</view>
<view class="sku-td flex-1">
<input class="sku-input" type="number" v-model="sku.stock" />
</view>
<view class="sku-td flex-1">
<input class="sku-input" v-model="sku.sku_code" />
</view>
</view>
<view v-if="skus.length === 0" class="empty-sku">
<text>请先选择规格或添加属性</text>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text>商品标签:</text></view>
<view class="input-wrap">
<view class="mock-btn-select">选择标签</view>
<!-- Step 2: 商品详情 -->
<view v-if="activeStep === 2" class="step-content">
<view class="form-item row-align-start">
<view class="label"><text class="required">*</text><text>商品详情:</text></view>
<view class="input-wrap">
<textarea class="textarea-box" v-model="form.description" placeholder="请输入商品详细描述内容..." />
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text>商品状态:</text></view>
<view class="input-wrap">
<view class="radio-group-simple">
<view class="radio-simple on">
<text class="dot"></text>
<text>上架</text>
</view>
<view class="radio-simple">
<text class="dot"></text>
<text>下架</text>
</view>
</view>
<!-- 其他步骤占位 -->
<view v-if="activeStep > 2" class="step-content">
<view class="placeholder-step">
<text>步骤 {{ steps[activeStep] }} 逻辑接入中...</text>
<text class="sub-tip">该模块涉及物流模板、会员等级定价等,将在后续阶段补齐</text>
</view>
</view>
</view>
<view class="footer-btns">
<button class="btn-next">一步</button>
<button class="btn-save">保存</button>
<button v-if="activeStep > 0" class="btn-prev" @click="prevStep">一步</button>
<button v-if="activeStep < steps.length - 1" class="btn-next" @click="nextStep">下一步</button>
<button v-if="activeStep === steps.length - 1" class="btn-save" @click="handleSave">保存</button>
</view>
<!-- 标签选择弹窗 -->
<view v-if="showLabelModal" class="popup-mask" @click="showLabelModal = false">
<view class="popup-card" @click.stop>
<view class="popup-header">
<text class="popup-title">选择商品标签</text>
<text class="popup-close" @click="showLabelModal = false">×</text>
</view>
<scroll-view class="popup-body" scroll-y="true" style="height: 300px;">
<view class="label-grid">
<view
v-for="label in labelList"
:key="label.id"
class="label-check-item"
:class="{ selected: form.tags.includes(label.name) }"
@click="toggleTag(label.name)"
>
<text class="label-check-txt">{{ label.name }}</text>
</view>
</view>
</scroll-view>
<view class="popup-footer">
<button class="btn primary" @click="showLabelModal = false">完成</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import {
fetchAdminProductDetail,
saveAdminProduct,
AdminProduct,
AdminProductSku
} from '@/services/admin/productService.uts'
import { fetchAdminCategoryList, AdminCategory } from '@/services/admin/productCategoryService.uts'
import { fetchLabels, ProductLabel } from '@/services/admin/productLabelService.uts'
import { fetchSpecTemplates, fetchParamTemplates, ProductSpecTemplate, ProductParamTemplate } from '@/services/admin/productSpecParamService.uts'
// --- 状态定义 ---
const activeStep = ref(0)
const steps = ['基础信息', '规格库存', '商品详情', '物流设置', '会员价/佣金', '营销设置', '其他设置']
const isLoading = ref(false)
const productId = ref<string | null>(null)
const carouselImages = ref([
'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
'https://img2.baidu.com/it/u=3025255470,3051061730&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
'https://img2.baidu.com/it/u=3775079632,546700868&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
])
// 响应式表单数据
const form = reactive({
name: '',
subtitle: '',
unit: '件',
category_id: '',
image_urls: [] as string[],
video_urls: [] as string[],
tags: [] as string[],
status: 1, // 1:上架, 2:下架
description: '',
price: 0,
stock: 0,
product_code: '',
attributes: {} as any
})
const categories = ref(['生活家居', '运动专区 / 361', '运动专区 / 特步', '运动专区 / 匹克'])
const skus = ref<AdminProductSku[]>([])
// 基础资料列表
const categories = ref<AdminCategory[]>([])
const labelList = ref<ProductLabel[]>([])
const specTemplates = ref<ProductSpecTemplate[]>([])
const paramTemplates = ref<ProductParamTemplate[]>([])
// --- UI 控制状态 ---
const showLabelModal = ref(false)
const categoryIndex = ref(0)
// 计算属性:分类名称列表供 picker 使用
const categoryLabels = computed((): string[] => {
return categories.value.map(c => c.name)
})
// 计算属性:当前选中分类名称
const selectedCategoryName = computed((): string => {
const cate = categories.value.find(c => c.id === form.category_id)
return cate != null ? cate.name : ''
})
// 计算属性:规格模板名称列表
const specTemplateLabels = computed((): string[] => {
return specTemplates.value.map(t => t.name)
})
onMounted(() => {
// 1. 获取商品 ID
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options as Record<string, string | undefined>
productId.value = options['id'] ?? null
// 2. 初始化所有基础资料
initBaseData()
// 3. 如果是编辑模式,加载详情
if (productId.value != null) {
loadProductDetail(productId.value!)
}
})
async function initBaseData() {
try {
const [cateRes, labelRes, specRes, paramRes] = await Promise.all([
fetchAdminCategoryList({ isActive: true }),
fetchLabels(),
fetchSpecTemplates(),
fetchParamTemplates()
])
categories.value = cateRes
labelList.value = labelRes
specTemplates.value = specRes
paramTemplates.value = paramRes
} catch (e) {
console.error('加载基础资料失败:', e)
}
}
async function loadProductDetail(id: string) {
isLoading.value = true
try {
const res = await fetchAdminProductDetail(id)
if (res.product != null) {
const p = res.product!
form.name = p.name
form.subtitle = p.subtitle ?? ''
form.unit = p.unit ?? '件'
form.category_id = p.category_id
form.image_urls = p.image_urls ?? []
form.video_urls = p.video_urls ?? []
form.tags = p.tags ?? []
form.status = p.status
form.description = p.description ?? ''
form.price = p.price
form.stock = p.stock
form.product_code = p.product_code ?? ''
form.attributes = p.attributes ?? {}
skus.value = res.skus
}
} catch (e) {
uni.showToast({ title: '加载详情失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
// --- 交互逻辑 ---
function goBack() {
openRoute('product_productList')
}
function nextStep() {
if (activeStep.value < steps.length - 1) {
activeStep.value++
}
}
function prevStep() {
if (activeStep.value > 0) {
activeStep.value--
}
}
async function handleSave() {
isLoading.value = true
try {
const productData: Partial<AdminProduct> = {
id: productId.value ?? undefined,
...form
}
const success = await saveAdminProduct(productData, skus.value)
if (success) {
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => goBack(), 1500)
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '系统异常', icon: 'none' })
} finally {
isLoading.value = false
}
}
// 模拟图片上传
function onUploadCarousel() {
uni.chooseImage({
count: 10 - form.image_urls.length,
success: (res) => {
form.image_urls.push(...res.tempFilePaths)
}
})
}
function removeImage(index: number) {
form.image_urls.splice(index, 1)
}
function onCategoryChange(e: any) {
const index = parseInt(String(e.detail.value))
categoryIndex.value = index
form.category_id = categories.value[index].id
}
function toggleTag(name: string) {
const index = form.tags.indexOf(name)
if (index > -1) {
form.tags.splice(index, 1)
} else {
form.tags.push(name)
}
}
function removeTag(name: string) {
const index = form.tags.indexOf(name)
if (index > -1) {
form.tags.splice(index, 1)
}
}
// --- 规格与 SKU 核心逻辑 ---
function formatSpecs(specs: any): string {
if (specs == null) return '默认规格'
if (typeof specs === 'object') {
const vals = Object.values(specs as Record<string, any>)
return vals.join(',')
}
return String(specs)
}
function onSpecTemplateChange(e: any) {
const index = parseInt(String(e.detail.value))
const tpl = specTemplates.value[index]
if (tpl == null) return
// 解析规格字符串,例如 "颜色:红色,蓝色;尺寸:S,M"
const specGroups = tpl.specs.split(';')
const dimensions: Array<Array<{key: string, value: string}>> = []
specGroups.forEach(group => {
const parts = group.split(':')
if (parts.length === 2) {
const key = parts[0].trim()
const values = parts[1].split(',')
dimensions.push(values.map(v => ({ key, value: v.trim() })))
}
})
if (dimensions.length === 0) return
// 笛卡尔积生成组合
const combinations = cartesianProduct(dimensions)
// 转换为 SKU 列表
skus.value = combinations.map(combo => {
const specObj: Record<string, string> = {}
combo.forEach(item => {
specObj[item.key] = item.value
})
return {
sku_code: '',
specifications: specObj,
price: form.price || 0,
stock: 0,
status: 1,
image_url: form.image_urls.length > 0 ? form.image_urls[0] : null
} as AdminProductSku
})
uni.showToast({ title: `生成了 ${skus.value.length} 个规格` })
}
/**
* 笛卡尔积辅助函数
*/
function cartesianProduct<T>(arrays: T[][]): T[][] {
const result: T[][] = [[]]
for (const array of arrays) {
const temp: T[][] = []
for (const res of result) {
for (const item of array) {
temp.push([...res, item])
}
}
if (temp.length > 0) result.length = 0, result.push(...temp)
}
return result
}
</script>
<style scoped lang="scss">
@@ -341,7 +692,158 @@ function goBack() {
justify-content: center;
gap: 16px;
padding-bottom: 40px;
.btn-prev { background: #fff; color: #666; border: 1px solid #d9d9d9; padding: 0 24px; height: 40px; border-radius: 4px; }
.btn-next { background: #1890ff; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; }
.btn-save { background: #fff; color: #1890ff; border: 1px solid #1890ff; padding: 0 24px; height: 40px; border-radius: 4px; }
.btn-save { background: #52c41a; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; }
}
/* SKU 表格样式 */
.sku-table-wrap {
margin-top: 20px;
border: 1px solid #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.sku-table-header {
display: flex;
flex-direction: row;
background-color: #fafafa;
height: 44px;
align-items: center;
border-bottom: 1px solid #f0f0f0;
}
.sku-th {
font-size: 14px;
font-weight: bold;
color: #333;
padding: 0 12px;
text-align: center;
}
.sku-tr {
display: flex;
flex-direction: row;
height: 54px;
align-items: center;
border-bottom: 1px solid #f0f0f0;
}
.sku-td {
padding: 0 12px;
font-size: 14px;
color: #666;
text-align: center;
}
.sku-input {
border: 1px solid #dcdfe6;
border-radius: 4px;
height: 32px;
width: 90%;
font-size: 13px;
text-align: center;
}
.empty-sku {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
.textarea-box {
width: 100%;
height: 300px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 12px;
font-size: 14px;
line-height: 1.6;
}
.placeholder-step {
padding: 100px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #999;
font-size: 16px;
.sub-tip { font-size: 13px; color: #ccc; }
}
.small-btn {
height: 32px;
line-height: 32px;
padding: 0 12px;
font-size: 13px;
border: 1px solid #dcdfe6;
background: #fff;
border-radius: 4px;
}
.row-align-start {
align-items: flex-start !important;
}
/* 弹窗与标签选择样式 */
.popup-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.popup-card {
width: 500px;
background-color: #fff;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.popup-header {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.popup-title { font-size: 16px; font-weight: bold; }
.popup-close { font-size: 20px; color: #999; cursor: pointer; }
.popup-body { padding: 20px; }
.label-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
}
.label-check-item {
padding: 6px 16px;
background-color: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
&.selected {
background-color: #e6f7ff;
border-color: #1890ff;
.label-check-txt { color: #1890ff; }
}
}
.label-check-txt { font-size: 13px; color: #666; }
.popup-footer {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
.btn.primary {
background-color: #1890ff;
color: #fff;
border: none;
padding: 0 20px;
height: 32px;
border-radius: 4px;
font-size: 14px;
}
.flex-1 { flex: 1; }
.flex-2 { flex: 2; }
</style>

View File

@@ -9,37 +9,170 @@
</view>
<view class="content-card">
<view class="product-info">
<image class="p-img" src="https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500" mode="aspectFill" />
<text class="p-name">UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫</text>
<view v-if="isLoading" class="loading-box">
<text>加载中...</text>
</view>
<view class="price-table">
<view class="th-row">
<view class="th flex-2"><text>规格名称</text></view>
<view class="th flex-1"><text>售价</text></view>
<view class="th flex-1"><text>普通会员价</text></view>
<view class="th flex-1"><text>黄金会员价</text></view>
<view class="th flex-1"><text>铂金会员价</text></view>
<template v-else>
<view class="product-info">
<image class="p-img" :src="productImage || '/static/logo.png'" mode="aspectFill" />
<text class="p-name">{{ productName }}</text>
</view>
<view class="tr-row">
<view class="td flex-2"><text>XL,卡其</text></view>
<view class="td flex-1"><text>¥99.00</text></view>
<view class="td flex-1"><input class="price-input" value="89.00" /></view>
<view class="td flex-1"><input class="price-input" value="79.00" /></view>
<view class="td flex-1"><input class="price-input" value="69.00" /></view>
</view>
</view>
<view class="footer-btns">
<button class="btn-save">保存设置</button>
</view>
<view class="price-table">
<view class="th-row">
<view class="th flex-2"><text>规格名称</text></view>
<view class="th flex-1"><text>售价</text></view>
<view v-for="level in levels" :key="level.id" class="th flex-1">
<text>{{ level.name }}</text>
</view>
</view>
<view v-if="skus.length === 0" class="empty-table">
<text>该商品暂无规格信息</text>
</view>
<view v-for="(sku, sIdx) in skus" :key="sku.id" class="tr-row">
<view class="td flex-2"><text>{{ formatSpecs(sku.specifications) }}</text></view>
<view class="td flex-1"><text>¥{{ sku.price.toFixed(2) }}</text></view>
<view v-for="level in levels" :key="level.id" class="td flex-1">
<input
class="price-input"
type="digit"
v-model="priceMatrix[sku.id][level.id]"
:placeholder="calcDefaultPrice(sku.price, level.discount_percent)"
/>
</view>
</view>
</view>
<view class="footer-btns">
<button class="btn-save" :disabled="isSaving" @click="handleSave">保存设置</button>
</view>
</template>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, reactive } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import {
fetchActiveUserLevels,
fetchProductSkus,
fetchMemberPrices,
saveMemberPrices,
UserLevel,
ProductSku
} from '@/services/admin/productMemberPriceService.uts'
// --- State ---
const productId = ref<string>('')
const productName = ref<string>('加载中...')
const productImage = ref<string>('')
const levels = ref<UserLevel[]>([])
const skus = ref<ProductSku[]>([])
const priceMatrix = reactive<Record<string, Record<string, string>>>({})
const isLoading = ref(true)
const isSaving = ref(false)
onMounted(() => {
// 从路由参数获取 productId
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options as Record<string, string | undefined>
productId.value = options['id'] ?? ''
if (!productId.value) {
uni.showToast({ title: '未找到商品ID', icon: 'none' })
return
}
initData()
})
async function initData() {
isLoading.value = true
try {
// 并行获取等级、SKU和已设价格
const [levelRes, skuRes, priceRes] = await Promise.all([
fetchActiveUserLevels(),
fetchProductSkus(productId.value),
fetchMemberPrices(productId.value)
])
levels.value = levelRes
skus.value = skuRes
// 初始化矩阵并填充已设价格
skuRes.forEach(sku => {
priceMatrix[sku.id] = {}
levelRes.forEach(level => {
// 查找是否已有定价
const existing = priceRes.find(p => p.sku_id === sku.id && p.level_id === level.id)
priceMatrix[sku.id][level.id] = existing != null ? String(existing.member_price) : ''
})
})
// 如果有 SKU拿第一个的信息展示在头部
if (skuRes.length > 0) {
// 实际开发中建议单独查一次 Product 信息,这里简化处理
productImage.value = skuRes[0].image_url ?? ''
}
} catch (e) {
uni.showToast({ title: '加载数据失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function formatSpecs(specs: any): string {
if (specs == null) return '默认规格'
// 假设规格存的是对象 { "颜色": "红色", "尺寸": "XL" }
if (typeof specs === 'object') {
const vals = Object.values(specs as Record<string, any>)
return vals.join(',')
}
return String(specs)
}
function calcDefaultPrice(basePrice: number, discount: number): string {
return (basePrice * discount / 100).toFixed(2)
}
async function handleSave() {
isSaving.value = true
try {
const saveRows: Array<{ sku_id: string; level_id: string; member_price: number }> = []
// 遍历矩阵,只保存有输入数值的项
Object.keys(priceMatrix).forEach(skuId => {
const levelPrices = priceMatrix[skuId]
Object.keys(levelPrices).forEach(levelId => {
const val = levelPrices[levelId]
if (val != null && val.trim() !== '') {
saveRows.push({
sku_id: skuId,
level_id: levelId,
member_price: parseFloat(val)
})
}
})
})
const ok = await saveMemberPrices(productId.value, saveRows)
if (ok) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '保存异常', icon: 'none' })
} finally {
isSaving.value = false
}
}
function goBack() {
openRoute('product_productList')
@@ -52,17 +185,21 @@ function goBack() {
.back-link { display: flex; flex-direction: row; align-items: center; gap: 4px; color: #666; cursor: pointer; }
.header-title { font-size: 16px; font-weight: bold; color: #333; }
}
.content-card { background: #fff; border-radius: 4px; padding: 24px; }
.content-card { background: #fff; border-radius: 4px; padding: 24px; min-height: 400px; }
.loading-box, .empty-table { padding: 60px 0; text-align: center; color: #999; font-size: 14px; }
.product-info { display: flex; flex-direction: row; align-items: center; gap: 16px; margin-bottom: 30px;
.p-img { width: 64px; height: 64px; border-radius: 4px; }
.p-name { font-size: 15px; font-weight: bold; color: #333; }
.p-img { width: 64px; height: 64px; border-radius: 4px; background: #f5f5f5; }
.p-name { font-size: 15px; font-weight: bold; color: #333; flex: 1; }
}
.price-table { border: 1px solid #f0f0f0; border-radius: 4px; }
.th-row { display: flex; flex-direction: row; background: #f8f9fa; border-bottom: 1px solid #f0f0f0; }
.th { padding: 12px; font-size: 14px; font-weight: 500; color: #333; text-align: center; }
.tr-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; &:last-child { border-bottom: none; } }
.price-table { border: 1px solid #f0f0f0; border-radius: 4px; overflow-x: auto; }
.th-row { display: flex; flex-direction: row; background: #f8f9fa; border-bottom: 1px solid #f0f0f0; min-width: 800px; }
.th { padding: 12px; font-size: 14px; font-weight: bold; color: #333; text-align: center; }
.tr-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-width: 800px; &:last-child { border-bottom: none; } }
.td { padding: 16px 12px; font-size: 14px; color: #666; text-align: center; display: flex; align-items: center; justify-content: center; }
.flex-1 { flex: 1; } .flex-2 { flex: 2; }
.price-input { border: 1px solid #dcdfe6; border-radius: 4px; height: 32px; padding: 0 8px; text-align: center; width: 80%; font-size: 13px; }
.footer-btns { margin-top: 40px; display: flex; justify-content: center; .btn-save { background: #1890ff; color: #fff; border: none; padding: 0 32px; height: 40px; border-radius: 4px; } }
.flex-1 { flex: 1; min-width: 120px; } .flex-2 { flex: 2; min-width: 200px; }
.price-input { border: 1px solid #dcdfe6; border-radius: 4px; height: 32px; padding: 0 8px; text-align: center; width: 90%; font-size: 13px; background: #fff; }
.footer-btns { margin-top: 40px; display: flex; justify-content: center; .btn-save { background: #1890ff; color: #fff; border: none; padding: 0 32px; height: 40px; border-radius: 4px; font-size: 14px;
&[disabled] { opacity: 0.6; }
} }
</style>

View File

@@ -8,11 +8,13 @@
<view class="info-icon">?</view>
</view>
<view class="header-right">
<view class="date-picker-wrap">
<text class="calendar-emoji">📅</text>
<text class="date-range">2026/01/04 - 2026/02/02</text>
</view>
<button class="btn-query">查询</button>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
<button class="btn-query" @click="onQuery">查询</button>
<button class="btn-export">导出</button>
</view>
</view>
@@ -122,6 +124,7 @@
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchAdminProductStats, fetchAdminProductTrend, fetchAdminProductRanking } from '@/services/admin/productService.uts'
const statItems = ref([
@@ -137,14 +140,38 @@ const rankingList = ref<Array<any>>([])
const chartOption = ref<any>({})
const loading = ref(false)
const startDate = ref<string>('')
const endDate = ref<string>('')
onMounted(() => {
// 默认最近 30 天(本地日期字符串)
const end = new Date()
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
startDate.value = start.toISOString().substring(0, 10)
endDate.value = end.toISOString().substring(0, 10)
loadAllData()
})
function onApplyRange(payload: any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
loadAllData()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
loadAllData()
}
function onQuery() {
loadAllData()
}
async function loadAllData() {
loading.value = true
const endTime = new Date().toISOString()
const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const startTime = startDate.value ? (startDate.value + ' 00:00:00') : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const endTime = endDate.value ? (endDate.value + ' 23:59:59') : new Date().toISOString()
try {
// 1. 加载核心指标

View File

@@ -104,25 +104,21 @@
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import {
fetchProductProtections,
saveProductProtection,
deleteProductProtection,
setProductProtectionActive,
ProductProtection
} from '@/services/admin/productProtectionService.uts'
interface ProtectionItem {
id: number;
name: string;
icon: string;
desc: string;
status: boolean;
sort: number;
}
const list = reactive<ProtectionItem[]>([
{ id: 1, name: '正品保障', icon: '/static/logo.png', desc: '该商品由平台认证,保证百分百正品。', status: true, sort: 0 },
{ id: 2, name: '七天无理由', icon: '/static/logo.png', desc: '商品在不影响二次销售的情况下支持7天无理由退换。', status: true, sort: 0 }
])
const list = ref<ProductProtection[]>([])
const isLoading = ref(false)
const showModal = ref(false)
const isEdit = ref(false)
const editIndex = ref(-1)
const editingId = ref<string | null>(null)
const form = reactive({
name: '',
@@ -132,17 +128,34 @@ const form = reactive({
sort: 0
})
function openModal(item: ProtectionItem | null = null) {
if (item) {
onMounted(() => {
loadData()
})
async function loadData() {
isLoading.value = true
try {
const res = await fetchProductProtections()
list.value = res
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function openModal(item: ProductProtection | null = null) {
if (item != null) {
isEdit.value = true
editingId.value = item.id ?? null
form.name = item.name
form.icon = item.icon
form.desc = item.desc
form.status = item.status
form.sort = item.sort
editIndex.value = list.indexOf(item)
form.icon = item.icon_url ?? ''
form.desc = item.description
form.status = item.is_active
form.sort = item.sort_order
} else {
isEdit.value = false
editingId.value = null
form.name = ''
form.icon = ''
form.desc = ''
@@ -161,43 +174,59 @@ function mockIconPicker() {
form.icon = '/static/logo.png'
}
function saveProtection() {
async function saveProtection() {
if (!form.name || !form.desc) {
uni.showToast({ title: '请输入必填项', icon: 'none' })
return
}
if (isEdit.value) {
const item = list[editIndex.value]
item.name = form.name
item.icon = form.icon
item.desc = form.desc
item.status = form.status
item.sort = form.sort
} else {
list.unshift({
id: Date.now() % 1000,
name: form.name,
icon: form.icon || '/static/logo.png',
desc: form.desc,
status: form.status,
sort: form.sort
})
isLoading.value = true
const protectionData: ProductProtection = {
id: editingId.value ?? undefined,
name: form.name,
icon_url: form.icon || '/static/logo.png',
description: form.desc,
is_active: form.status,
sort_order: form.sort
}
try {
const success = await saveProductProtection(protectionData)
if (success) {
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
loadData()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '操作异常', icon: 'none' })
} finally {
isLoading.value = false
}
closeModal()
uni.showToast({ title: '保存成功', icon: 'success' })
}
function toggleStatus(index: number) {
list[index].status = !list[index].status
async function toggleStatus(item: ProductProtection) {
if (item.id == null) return
const nextStatus = !item.is_active
const success = await setProductProtectionActive(item.id!, nextStatus)
if (success) {
item.is_active = nextStatus
}
}
function deleteItem(index: number) {
async function deleteItem(id: string | undefined) {
if (id == null) return
uni.showModal({
title: '提示',
content: '确定删除该保障条款吗?',
success: (res) => {
success: async (res) => {
if (res.confirm) {
list.splice(index, 1)
const success = await deleteProductProtection(id)
if (success) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})

View File

@@ -5,37 +5,40 @@
<view class="search-row">
<view class="search-item">
<text class="label">评价时间:</text>
<view class="mock-date-range">
<text class="emoji">📅</text>
<text class="txt">开始日期 - 结束日期</text>
</view>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
<view class="search-item">
<text class="label">评价状态:</text>
<view class="mock-select"><text>请选择</text><text class="arrow">▼</text></view>
</view>
<view class="search-item">
<text class="label">审核状态:</text>
<view class="mock-select"><text>请选择</text><text class="arrow">▼</text></view>
<picker :value="statusIndex" :range="statusOptions" range-key="label" @change="onStatusChange">
<view class="mock-select">
<text>{{ statusOptions[statusIndex].label }}</text>
<text class="arrow">▼</text>
</view>
</picker>
</view>
</view>
<view class="search-row mt-16">
<view class="search-item">
<text class="label">商品信息</text>
<input class="mock-input" placeholder="请输入商品信息" />
<text class="label">商品名称</text>
<input class="mock-input" v-model="searchProduct" placeholder="请输入商品信息" @confirm="onSearch" />
</view>
<view class="search-item">
<text class="label">用户名称:</text>
<input class="mock-input" placeholder="请输入" />
<input class="mock-input" v-model="searchUser" placeholder="请输入" @confirm="onSearch" />
</view>
<button class="btn-primary">查询</button>
<button class="btn-primary" @click="onSearch">查询</button>
<button class="btn-white" @click="onReset">重置</button>
</view>
</view>
<!-- 2. 操作行 -->
<view class="action-bar">
<button class="btn-primary">添加自评</button>
<button class="btn-white">批量审核</button>
<button class="btn-primary" @click="onAddSelfReview">添加自评</button>
</view>
<!-- 3. 数据表格 -->
@@ -45,7 +48,6 @@
<view class="th col-check"><text>□</text></view>
<view class="th col-id"><text>评论ID</text></view>
<view class="th col-product"><text>商品信息</text></view>
<view class="th col-spec"><text>规格</text></view>
<view class="th col-user"><text>用户名称</text></view>
<view class="th col-score"><text>评分</text></view>
<view class="th col-content"><text>评价内容</text></view>
@@ -55,40 +57,45 @@
<view class="th col-op"><text>操作</text></view>
</view>
<view v-for="item in replyList" :key="item.id" class="tr-row">
<view v-if="isLoading" class="table-empty" style="padding: 40px; text-align: center;">
<text>数据加载中...</text>
</view>
<view v-else-if="replyList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无评价数据</text>
</view>
<view v-else v-for="item in replyList" :key="item.id" class="tr-row">
<view class="td col-check"><text>□</text></view>
<view class="td col-id"><text>{{ item.id }}</text></view>
<view class="td col-product">
<image class="p-img" :src="item.image" mode="aspectFill" />
<text class="p-name-txt">{{ item.productName }}</text>
<image class="p-img" :src="item.product_image || '/static/logo.png'" mode="aspectFill" />
<text class="p-name-txt">{{ item.product_name }}</text>
</view>
<view class="td col-spec"><text>{{ item.spec }}</text></view>
<view class="td col-user"><text>{{ item.username }}</text></view>
<view class="td col-score"><text>{{ item.score }}</text></view>
<view class="td col-user"><text>{{ item.username || '游客' }}</text></view>
<view class="td col-score"><text>{{ item.rating }}</text></view>
<view class="td col-content"><text class="blue-link">{{ item.content }}</text></view>
<view class="td col-reply"><text>{{ item.reply || '无' }}</text></view>
<view class="td col-reply"><text>{{ item.merchant_reply || '无' }}</text></view>
<view class="td col-status">
<text class="status-tag" :class="item.status === 1 ? 'pass' : 'wait'">
{{ item.status === 1 ? '通过' : '待审核' }}
<text class="status-tag" :class="item.status === 1 ? 'pass' : (item.status === 3 ? 'fail' : 'wait')">
{{ item.status === 1 ? '通过' : (item.status === 3 ? '已驳回' : '待审核') }}
</text>
</view>
<view class="td col-time"><text>{{ item.time }}</text></view>
<view class="td col-time"><text>{{ formatDateTime(item.created_at) }}</text></view>
<view class="td col-op">
<text class="op-link">通过</text>
<text class="op-link">驳回</text>
<text class="op-link">回复</text>
<text class="op-link red">删除</text>
<text v-if="item.status !== 1" class="op-link" @click="handleApprove(item.id)">通过</text>
<text v-if="item.status !== 3" class="op-link" @click="handleReject(item.id)">驳回</text>
<text class="op-link" @click="handleReply(item)">回复</text>
<text class="op-link red" @click="handleDelete(item.id)">删除</text>
</view>
</view>
</view>
<!-- 分页 -->
<view class="pagination-row">
<text class="total">共 {{ replyList.length }} 条</text>
<text class="total">共 {{ total }} 条</text>
<view class="page-ctrl">
<text class="page-btn disabled">{"<"}</text>
<text class="page-num active">1</text>
<text class="page-btn">{">"}</text>
<text class="page-btn" :class="{ disabled: page <= 1 }" @click="onPrevPage">{"<"}</text>
<text class="page-num active">{{ page }}</text>
<text class="page-btn" :class="{ disabled: replyList.length < pageSize }" @click="onNextPage">{">"}</text>
</view>
</view>
</view>
@@ -96,46 +103,169 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchAdminProductReviews, approveProductReview, rejectProductReview, replyProductReview, deleteProductReview, type ProductReviewItem } from '@/services/admin/productReviewService.uts'
const replyList = ref([
{
id: 1069,
image: 'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
productName: 'UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060',
spec: 'XL,卡其',
username: 'demo998',
score: 3.5,
content: '22',
reply: '',
status: 0,
time: '2025-02-19 14:56:43'
},
{
id: 1059,
image: 'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
productName: 'UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060',
spec: 'XL,卡其',
username: '你好呀',
score: 3.5,
content: '的',
reply: '',
status: 0,
time: '2025-01-07 15:35:36'
},
{
id: 980,
image: 'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
productName: 'UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060',
spec: 'XL,卡其',
username: 'wx209638',
score: 5,
content: '好',
reply: '',
status: 1,
time: '2024-09-12 14:20:12'
}
const replyList = ref<Array<ProductReviewItem>>([])
const isLoading = ref(false)
const searchProduct = ref('')
const searchUser = ref('')
const statusOptions = ref<Array<{ label: string; value: number | null }>>([
{ label: '全部', value: null },
{ label: '通过', value: 1 },
{ label: '已驳回', value: 3 },
{ label: '已删除', value: 2 }
])
const statusIndex = ref(0)
const startDate = ref<string>('')
const endDate = ref<string>('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
onMounted(() => {
// 默认最近 30 天(本地日期)
const end = new Date()
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
startDate.value = start.toISOString().substring(0, 10)
endDate.value = end.toISOString().substring(0, 10)
loadData()
})
async function loadData() {
isLoading.value = true
try {
const startTime = startDate.value ? (startDate.value + ' 00:00:00') : null
const endTime = endDate.value ? (endDate.value + ' 23:59:59') : null
const res = await fetchAdminProductReviews({
searchProduct: searchProduct.value,
searchUser: searchUser.value,
status: statusOptions.value[statusIndex.value].value,
startTime,
endTime,
page: page.value,
pageSize
})
replyList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载评价失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function onApplyRange(payload: any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
page.value = 1
loadData()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
page.value = 1
loadData()
}
function onStatusChange(e: any) {
statusIndex.value = parseInt(String(e.detail.value))
page.value = 1
loadData()
}
function onSearch() {
page.value = 1
loadData()
}
function onReset() {
searchProduct.value = ''
searchUser.value = ''
statusIndex.value = 0
page.value = 1
loadData()
}
function onPrevPage() {
if (page.value <= 1) return
page.value--
loadData()
}
function onNextPage() {
if (replyList.value.length < pageSize) return
page.value++
loadData()
}
function onAddSelfReview() {
uni.showToast({ title: '添加自评开发中', icon: 'none' })
}
function formatDateTime(iso: string): string {
try {
return iso.replace('T', ' ').split('.')[0]
} catch (e) {
return iso
}
}
async function handleApprove(id: string) {
const ok = await approveProductReview(id)
if (ok) {
uni.showToast({ title: '已通过', icon: 'success' })
loadData()
}
}
async function handleReject(id: string) {
const ok = await rejectProductReview(id)
if (ok) {
uni.showToast({ title: '已驳回', icon: 'success' })
loadData()
}
}
async function handleDelete(id: string) {
uni.showModal({
title: '提示',
content: '确定删除该评价吗?',
success: async (res) => {
if (!res.confirm) return
const ok = await deleteProductReview(id)
if (ok) {
uni.showToast({ title: '已删除', icon: 'success' })
loadData()
}
}
})
}
async function handleReply(item: ProductReviewItem) {
uni.showModal({
title: '回复评价',
editable: true,
content: item.merchant_reply ?? '',
placeholderText: '请输入回复内容',
success: async (res) => {
if (!res.confirm) return
const content = res.content ?? ''
const ok = await replyProductReview(item.id, content)
if (ok) {
uni.showToast({ title: '回复成功', icon: 'success' })
loadData()
}
}
})
}
</script>
<style scoped lang="scss">

View File

@@ -5,16 +5,16 @@
<view class="search-row">
<view class="search-item">
<text class="search-label">规格搜索:</text>
<input class="search-input" placeholder="请输入规格名称" />
<input class="search-input" v-model="searchName" placeholder="请输入规格名称" @confirm="handleSearch" />
</view>
<button class="btn-query">查询</button>
<button class="btn-query" @click="handleSearch">查询</button>
</view>
</view>
<!-- 数据表格区域 -->
<view class="table-card">
<view class="table-toolbar">
<button class="btn-add" @click="showModal = true">添加商品规格</button>
<button class="btn-add" @click="openAddModal">添加商品规格</button>
<button class="btn-batch-del">批量删除</button>
</view>
@@ -44,9 +44,9 @@
<text class="td-cell flex-4">{{ item.specs }}</text>
<text class="td-cell flex-4">{{ item.attrs }}</text>
<view class="td-cell flex-2 row-center">
<text class="btn-link">编辑</text>
<text class="btn-link" @click="openEditModal(item)">编辑</text>
<view class="divider"></view>
<text class="btn-link delete" @click="deleteItem(index)">删除</text>
<text class="btn-link delete" @click="deleteItem(item.id)">删除</text>
</view>
</view>
</view>
@@ -91,57 +91,112 @@
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { fetchSpecTemplates, saveSpecTemplate, deleteSpecTemplate, ProductSpecTemplate } from '@/services/admin/productSpecParamService.uts'
interface AttrItem {
id: number;
name: string;
specs: string;
attrs: string;
interface AttrItem extends ProductSpecTemplate {
selected: boolean;
}
const list = reactive<AttrItem[]>([
{ id: 104, name: '颜色', specs: '红色,蓝色,黑色,白色', attrs: '颜色属性', selected: false },
{ id: 105, name: '尺寸', specs: 'S,M,L,XL,XXL', attrs: '服装尺寸', selected: false },
{ id: 106, name: '材质', specs: '纯棉,涤纶,真丝', attrs: '面料材质', selected: false },
{ id: 107, name: '内存', specs: '8G,16G,32G', attrs: '硬件参数', selected: false },
{ id: 108, name: '存储', specs: '128G,256G,512G', attrs: '容量', selected: false }
])
const list = ref<AttrItem[]>([])
const isLoading = ref(false)
const searchName = ref('')
const showModal = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
const form = reactive({
name: '',
specs: '',
attrs: ''
})
function saveAttr() {
onMounted(() => {
loadData()
})
async function loadData() {
isLoading.value = true
try {
const res = await fetchSpecTemplates(searchName.value)
list.value = res.map(item => ({
...item,
selected: false
} as AttrItem))
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function handleSearch() {
loadData()
}
function openAddModal() {
isEdit.value = false
editingId.value = null
form.name = ''
form.specs = ''
form.attrs = ''
showModal.value = true
}
function openEditModal(item : AttrItem) {
isEdit.value = true
editingId.value = item.id ?? null
form.name = item.name
form.specs = item.specs
form.attrs = item.attrs
showModal.value = true
}
async function saveAttr() {
if (!form.name) {
uni.showToast({ title: '请输入规格名称', icon: 'none' })
return
}
list.push({
id: Math.floor(Math.random() * 1000),
isLoading.value = true
const tpl : ProductSpecTemplate = {
id: editingId.value ?? undefined,
name: form.name,
specs: form.specs,
attrs: form.attrs,
selected: false
})
showModal.value = false
form.name = ''
form.specs = ''
form.attrs = ''
uni.showToast({ title: '添加成功', icon: 'success' })
sort_order: 0,
is_active: true
}
try {
const success = await saveSpecTemplate(tpl)
if (success) {
uni.showToast({ title: isEdit.value ? '修改成功' : '添加成功', icon: 'success' })
showModal.value = false
loadData()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '操作异常', icon: 'none' })
} finally {
isLoading.value = false
}
}
function deleteItem(index: number) {
async function deleteItem(id : string | undefined) {
if (id == null) return
uni.showModal({
title: '提示',
content: '确定删除该规格吗?',
success: (res) => {
success: async (res) => {
if (res.confirm) {
list.splice(index, 1)
const success = await deleteSpecTemplate(id)
if (success) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})