修复bug

This commit is contained in:
2026-03-20 15:24:59 +08:00
parent 14b506036c
commit 29f588a2b2
13 changed files with 321 additions and 2003 deletions

View File

@@ -106,6 +106,7 @@
.actions { .actions {
display: flex; display: flex;
flex-direction: row;
gap: 8px; gap: 8px;
} }

View File

@@ -68,7 +68,10 @@
<view class="content-scroll"> <view class="content-scroll">
<view class="content-inner" :class="{ 'is-mobile': isMobile }"> <view class="content-inner" :class="{ 'is-mobile': isMobile }">
<slot v-if="hasAccess"></slot> <slot v-if="hasAccess"></slot>
<component :is="currentComponent" v-if="hasAccess && !isPageLoading && currentComponent != null"></component> <!-- currentPage 为空字符串说明是顶层路由容器,才允许渲染动态组件;
若 currentPage 非空,说明是子页面自己套了 AdminLayout作为布局壳使用
此时不再渲染 currentComponent避免递归挂载导致重复请求。 -->
<component :is="currentComponent" v-if="hasAccess && !isPageLoading && currentComponent != null && props.currentPage === ''"></component>
<AdminPageLoading v-if="isPageLoading"></AdminPageLoading> <AdminPageLoading v-if="isPageLoading"></AdminPageLoading>
</view> </view>
<AdminFooter></AdminFooter> <AdminFooter></AdminFooter>

View File

@@ -1,270 +0,0 @@
<template>
<view class="admin-decoration-category">
<!-- 顶部标题与按钮 -->
<view class="page-header border-shadow">
<view class="header-left">
<text class="page-title">商品分类装修</text>
</view>
<view class="header-right">
<view class="btn-primary" @click="handleSave">
<text class="btn-txt">{{ isSaving ? '保存中...' : '保存配置' }}</text>
</view>
<view class="btn-ghost" @click="handleReset">
<text class="ghost-txt">重置</text>
</view>
</view>
</view>
<!-- 分类展示区域 -->
<view class="content-container">
<view v-if="isLoading" class="loading-state">
<text>加载配置中...</text>
</view>
<view v-else class="style-list">
<!-- 样式1 -->
<view class="style-card-wrapper">
<view :class="['style-card', selectedStyle === 1 ? 'active' : '']" @click="selectedStyle = 1">
<view class="phone-mock">
<view class="phone-header">
<text class="p-title">产品分类</text>
<text class="p-dots">••• Ⓞ</text>
</view>
<view class="phone-body">
<view class="search-bar">
<text class="ic-search">🔍</text>
<text class="search-ph">点击搜索商品信息</text>
</view>
<view class="style1-content">
<view class="sidebar-mock">
<text class="sb-item active">精选水果</text>
<text class="sb-item" v-for="name in ['肉制品','水产海鲜','米面粮油','厨房主食','新鲜蛋品','调味品','日配冷藏','豆制品']" :key="name">{{name}}</text>
</view>
<view class="main-mock">
<view class="category-section">
<view class="section-title"><text class="st-txt">精选水果</text></view>
<view class="grid-container">
<view class="grid-item" v-for="i in 6" :key="i">
<view class="item-img-box"><text class="item-placeholder">🍐</text></view>
<text class="item-txt">{{ ['精品香蕉','坚果优选','猕猴桃','大肉块','五花肉','鸡腿'][i-1] }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="phone-tabbar">
<view class="tb-item"><text class="tb-ic">🏠</text></view>
<view class="tb-item active"><text class="tb-ic">📂</text></view>
<view class="tb-item"><text class="tb-ic">👤</text></view>
</view>
</view>
</view>
<text class="style-name" :style="{color: selectedStyle === 1 ? '#2d8cf0' : '#666'}">样式1</text>
</view>
<!-- 样式2 -->
<view class="style-card-wrapper">
<view :class="['style-card', selectedStyle === 2 ? 'active' : '']" @click="selectedStyle = 2">
<view class="phone-mock">
<view class="phone-header-img"></view>
<view class="phone-header-v2">
<text class="p2-title">分类</text>
<view class="home-ic">🏠</view>
</view>
<view class="phone-body p2-body">
<view class="search-bar-v2">
<text class="ic-search">🔍</text>
<text class="search-ph">点击搜索商品信息</text>
</view>
<view class="style2-content">
<view class="sidebar-v2">
<text class="s2-item active">乳品</text>
<text class="s2-item" v-for="n in 5" :key="n">分类{{n}}</text>
</view>
<view class="main-v2">
<view class="banner-mock-v2">
<text class="b-txt">深层 V8 高清直屏\n双镜头/VR科技体验</text>
</view>
<view class="prod-v2" v-for="i in 2" :key="i">
<text class="p-name">精选爆款商品标题示例内容展示</text>
<view class="p-price-row">
<text class="p-price">¥99.00</text>
<view class="btn-buy"><text class="buy-txt">立即购买</text></view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<text class="style-name" :style="{color: selectedStyle === 2 ? '#2d8cf0' : '#666'}">样式2</text>
</view>
<!-- 样式3 -->
<view class="style-card-wrapper">
<view :class="['style-card', selectedStyle === 3 ? 'active' : '']" @click="selectedStyle = 3">
<view class="phone-mock">
<view class="phone-header">
<text class="p-title">产品分类</text>
</view>
<view class="phone-body">
<view class="search-bar-v3">
<view class="home-btn">🏠</view>
<view class="search-input-v3">
<text class="ic-search">🔍</text>
<text class="search-ph">搜索商品</text>
</view>
</view>
<view class="style3-content">
<view class="sidebar-v3">
<text class="s3-item active">乳品</text>
<text class="s3-item" v-for="n in 5" :key="n">分类{{n}}</text>
</view>
<view class="main-v3">
<view class="prod-v3" v-for="i in 5" :key="i">
<view class="pv-img"></view>
<view class="pv-info">
<text class="pv-name">优质精选商品名称展示示例</text>
<text class="pv-price">¥25.99</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<text class="style-name" :style="{color: selectedStyle === 3 ? '#2d8cf0' : '#666'}">样式3</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { getActiveDiyConfig, saveDiyPage, type DiyPage } from '@/services/admin/decorationService.uts'
const selectedStyle = ref(1)
const isLoading = ref(false)
const isSaving = ref(false)
const currentPageId = ref<string | null>(null)
onMounted(() => {
loadConfig()
})
async function loadConfig() {
isLoading.value = true
try {
const config = await getActiveDiyConfig('category')
if (config != null) {
currentPageId.value = config.id
const style = config.config.getNumber('style')
if (style != null) {
selectedStyle.value = style.toInt()
}
}
} catch (e) {
console.error('Failed to load category decoration config', e)
} finally {
isLoading.value = false
}
}
const handleSave = async () => {
isSaving.value = true
try {
const config = { style: selectedStyle.value } as UTSJSONObject
const id = await saveDiyPage(currentPageId.value, '商品分类默认配置', 'category', config, true)
if (id != null) {
currentPageId.value = id
uni.showToast({ title: '保存成功', icon: 'success' })
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
isSaving.value = false
}
}
const handleReset = () => {
selectedStyle.value = 1
}
</script>
<style scoped lang="scss">
.admin-decoration-category { background-color: #f0f2f5; min-height: 100vh; display: flex; flex-direction: column; }
.border-shadow { background-color: #fff; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.page-header { height: 60px; padding: 0 24px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; z-index: 10; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; flex-direction: row; gap: 12px; }
.btn-primary, .btn-ghost { height: 32px; padding: 0 20px; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-primary { background-color: #2d8cf0; }
.btn-ghost { border: 1px solid #dcdfe6; position: relative; }
.btn-txt { color: #fff; font-size: 14px; }
.ghost-txt { color: #606266; font-size: 14px; }
.content-container { flex: 1; padding: 30px 40px; }
.loading-state { padding: 100px; text-align: center; color: #999; }
.style-list { display: flex; flex-direction: row; gap: 30px; flex-wrap: wrap; }
.style-card-wrapper { display: flex; flex-direction: column; align-items: center; gap: 15px; }
.style-card { width: 300px; height: 600px; background-color: #fff; border: 2px solid transparent; border-radius: 20px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); cursor: pointer; transition: all 0.3s; }
.style-card.active { border-color: #2d8cf0; box-shadow: 0 4px 20px rgba(45, 140, 240, 0.2); }
.style-name { font-size: 14px; font-weight: bold; }
/* Phone Mockup Common */
.phone-mock { width: 100%; height: 100%; display: flex; flex-direction: column; background-color: #fff; }
.phone-header { height: 44px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 0 15px; border-bottom: 1px solid #eee; }
.p-title { font-size: 14px; font-weight: bold; color: #333; }
.p-dots { font-size: 12px; color: #333; }
.phone-body { flex: 1; background-color: #f8f8f8; display: flex; flex-direction: column; overflow: hidden; }
/* Styles logic */
.style1-content, .style2-content, .style3-content { flex: 1; display: flex; flex-direction: row; }
.sidebar-mock, .sidebar-v2, .sidebar-v3 { width: 70px; background-color: #f7f7f7; display: flex; flex-direction: column; }
.sb-item, .s2-item, .s3-item { height: 44px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #666; }
.sb-item.active, .s2-item.active, .s3-item.active { background-color: #fff; color: #f2270c; font-weight: bold; position: relative; }
.sb-item.active::before, .s3-item.active::before { content: ''; position: absolute; left: 0; top: 15px; height: 14px; width: 3px; background-color: #f2270c; }
.main-mock, .main-v2, .main-v3 { flex: 1; background-color: #fff; padding: 10px; }
.search-bar, .search-bar-v2, .search-bar-v3 { height: 32px; background-color: #f2f2f2; margin: 10px; border-radius: 16px; display: flex; flex-direction: row; align-items: center; padding: 0 12px; }
.ic-search { font-size: 12px; margin-right: 6px; color: #999; }
.search-ph { font-size: 10px; color: #999; }
.category-section { margin-bottom: 10px; }
.st-txt { font-size: 12px; font-weight: bold; color: #333; }
.grid-container { display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; }
.grid-item { width: 60px; display: flex; flex-direction: column; align-items: center; margin-bottom: 10px; }
.item-img-box { width: 44px; height: 44px; background-color: #f5f5f5; border-radius: 4px; display: flex; align-items: center; justify-content: center; margin-bottom: 4px; }
.item-txt { font-size: 9px; color: #666; text-align: center; }
.phone-tabbar { height: 48px; display: flex; flex-direction: row; border-top: 1px solid #eee; }
.tb-item { flex: 1; display: flex; align-items: center; justify-content: center; font-size: 18px; color: #999; }
.tb-item.active { color: #f2270c; }
.banner-mock-v2 { height: 70px; background-color: #0081ff; border-radius: 6px; padding: 10px; display: flex; align-items: center; margin-bottom: 10px; }
.b-txt { font-size: 10px; color: #fff; font-weight: bold; }
.prod-v2 { border-bottom: 1px solid #f5f5f5; padding-bottom: 8px; margin-bottom: 8px; }
.p-name { font-size: 10px; color: #333; line-height: 1.3; }
.p-price { font-size: 11px; color: #f2270c; font-weight: bold; }
.btn-buy { background-color: #f2270c; padding: 3px 8px; border-radius: 10px; }
.buy-txt { font-size: 8px; color: #fff; }
.prod-v3 { display: flex; flex-direction: row; margin-bottom: 10px; }
.pv-img { width: 60px; height: 60px; background-color: #f5f5f5; border-radius: 4px; margin-right: 8px; }
.pv-name { font-size: 10px; color: #333; }
.pv-price { font-size: 12px; color: #f2270c; font-weight: bold; }
.settings-panel { flex: 1; padding: 30px; }
.group-title { display: flex; flex-direction: row; align-items: center; margin-bottom: 20px; }
.title-line { width: 3px; height: 16px; background-color: #2d8cf0; margin-right: 10px; }
.title-txt { font-size: 15px; font-weight: bold; }
.radio-group { display: flex; flex-direction: column; gap: 15px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-dot { width: 16px; height: 16px; border: 1px solid #dcdfe6; border-radius: 8px; margin-right: 10px; }
.radio-dot.active { border-color: #2d8cf0; background-color: #2d8cf0; }
.radio-txt { font-size: 14px; color: #333; }
.hint-txt { font-size: 12px; color: #999; margin-top: 20px; line-height: 1.6; }
</style>

View File

@@ -281,31 +281,35 @@
❌ select() 调用没有 limit/offset 参数 ❌ select() 调用没有 limit/offset 参数
``` ```
- **正确实现(服务端分页模板)**: - **正确实现(服务端分页模板)**:
```typescript ```typescript
// ✅ state // ✅ state
const total = ref(0) // 来自服务端,不是 .length const total = ref(0); // 来自服务端,不是 .length
const pagedList = computed(() => userList.value) // 无前端 slice服务端已分页 const pagedList = computed(() => userList.value); // 无前端 slice服务端已分页
// ✅ fetch每次翻页都重新请求 // ✅ fetch每次翻页都重新请求
const fetchUsers = async (page: number, ps: number) => { const fetchUsers = async (page: number, ps: number) => {
const offset = (page - 1) * ps const offset = (page - 1) * ps;
const offsetFilter = offset > 0 ? `offset=${offset}` : null const offsetFilter = offset > 0 ? `offset=${offset}` : null;
const res = await supabase.select('table', offsetFilter, { const res = await supabase.select("table", offsetFilter, {
limit: ps, order: 'created_at.desc', count: 'exact' limit: ps,
}) order: "created_at.desc",
userList.value = (res.data as UTSJSONObject[]).map(mapDbRow) count: "exact",
});
userList.value = (res.data as UTSJSONObject[]).map(mapDbRow);
// 从 Content-Range 头解析总数(见原因二十八) // 从 Content-Range 头解析总数(见原因二十八)
total.value = parseTotalFromContentRange(res) total.value = parseTotalFromContentRange(res);
} };
// ✅ 翻页 handler每次翻页都调用 fetch // ✅ 翻页 handler每次翻页都调用 fetch
const handlePageChange = (p: number) => { const handlePageChange = (p: number) => {
currentPage.value = p currentPage.value = p;
fetchUsers(p, pageSize.value) fetchUsers(p, pageSize.value);
} };
onMounted(() => fetchUsers(1, pageSize.value)) onMounted(() => fetchUsers(1, pageSize.value));
``` ```
- **强制规则**: 任何使用 Supabase 的列表页面,`total` 必须来自后端 `Content-Range` 响应头或 `res.count`,严禁使用 `computed(() => localArray.value.length)` 作为总数。 - **强制规则**: 任何使用 Supabase 的列表页面,`total` 必须来自后端 `Content-Range` 响应头或 `res.count`,严禁使用 `computed(() => localArray.value.length)` 作为总数。
#### **原因二十七(补充):已在另一位置记录(响应式预览网格)** #### **原因二十七(补充):已在另一位置记录(响应式预览网格)**
@@ -321,51 +325,56 @@
3. UI 层的"禁用"保护(`if (p > totalPage) return`)在 `total` 来自错误来源时本身就不可靠(见原因二十六),因此无法阻止越界请求。 3. UI 层的"禁用"保护(`if (p > totalPage) return`)在 `total` 来自错误来源时本身就不可靠(见原因二十六),因此无法阻止越界请求。
4. 即使 UI 保护正确,在数据量恰好是页大小整数倍时(如共 15 条、每页 15 条),请求第 2 页仍会触发 416`rangeFrom=15 >= total=15`)。 4. 即使 UI 保护正确,在数据量恰好是页大小整数倍时(如共 15 条、每页 15 条),请求第 2 页仍会触发 416`rangeFrom=15 >= total=15`)。
- **错误修复思路(踩坑路径)**: - **错误修复思路(踩坑路径)**:
``` ```
❌ 第一轮尝试:在 handlePageChange / onPageBtnClick 中加 if (p > totalPage) return ❌ 第一轮尝试:在 handlePageChange / onPageBtnClick 中加 if (p > totalPage) return
结果:仍然 416。因为 UI 保护只能拦截按钮点击,无法修复协议层行为。 结果:仍然 416。因为 UI 保护只能拦截按钮点击,无法修复协议层行为。
❌ 第二轮尝试:在 fetchUsers 中加 if (res.status === 416) { ... } 分支处理 ❌ 第二轮尝试:在 fetchUsers 中加 if (res.status === 416) { ... } 分支处理
结果:错误被吞但数据仍为空,用户体验差,根本原因未消除。 结果:错误被吞但数据仍为空,用户体验差,根本原因未消除。
``` ```
- **正确修复方案**: - **正确修复方案**:
```typescript ```typescript
// ❌ 禁止使用 Range 头分页 // ❌ 禁止使用 Range 头分页
const res = await supabase const res = await supabase
.from('ak_users') .from("ak_users")
.select(columns) .select(columns)
.page(page) // ← 产生 Range 头 .page(page) // ← 产生 Range 头
.limit(pageSize) // ← 产生 Range 头 .limit(pageSize) // ← 产生 Range 头
.execute() .execute();
// ✅ 使用 URL 参数分页(永远不返回 416 // ✅ 使用 URL 参数分页(永远不返回 416
const offset = (page - 1) * ps const offset = (page - 1) * ps;
const offsetFilter = offset > 0 ? `offset=${offset}` : null const offsetFilter = offset > 0 ? `offset=${offset}` : null;
const res = await supabase.select('ak_users', offsetFilter, { const res = await supabase.select("ak_users", offsetFilter, {
columns: 'id, username, ...', columns: "id, username, ...",
limit: ps, limit: ps,
order: 'created_at.desc', order: "created_at.desc",
count: 'exact' // → Prefer: count=exact → 响应包含 Content-Range: 0-14/26 count: "exact", // → Prefer: count=exact → 响应包含 Content-Range: 0-14/26
}) });
// PostgREST 对 ?offset=N&limit=N 参数offset 超出 total 时返回 200 + 空数组,绝不 416 // PostgREST 对 ?offset=N&limit=N 参数offset 超出 total 时返回 200 + 空数组,绝不 416
``` ```
- **总数解析Content-Range 手动解析)**: - **总数解析Content-Range 手动解析)**:
```typescript ```typescript
// 当 aksupa 使用 count: 'exact' 时,响应头会包含 Content-Range: 0-14/26 // 当 aksupa 使用 count: 'exact' 时,响应头会包含 Content-Range: 0-14/26
// 必须手动解析,因为绕过了 .page().limit() 的自动汇总逻辑 // 必须手动解析,因为绕过了 .page().limit() 的自动汇总逻辑
const parseTotal = (res: AkReqResponse): number => { const parseTotal = (res: AkReqResponse): number => {
const cr = (res.headers?.['content-range'] ?? res.headers?.['Content-Range']) as string | null const cr = (res.headers?.["content-range"] ??
res.headers?.["Content-Range"]) as string | null;
if (cr) { if (cr) {
const slash = cr.lastIndexOf('/') const slash = cr.lastIndexOf("/");
if (slash !== -1) { if (slash !== -1) {
const n = parseInt(cr.substring(slash + 1), 10) const n = parseInt(cr.substring(slash + 1), 10);
if (!isNaN(n)) return n if (!isNaN(n)) return n;
} }
} }
// 降级:至少知道 offset + 当前页行数 // 降级:至少知道 offset + 当前页行数
return offset + (res.data as UTSJSONObject[]).length return offset + (res.data as UTSJSONObject[]).length;
} };
total.value = parseTotal(res) total.value = parseTotal(res);
``` ```
- **强制规则**: - **强制规则**:
1. **项目内所有列表页禁止使用 `.page().limit()` 链式调用**aksupa 或其他类似封装)。 1. **项目内所有列表页禁止使用 `.page().limit()` 链式调用**aksupa 或其他类似封装)。
@@ -378,28 +387,30 @@
- **现象**: 在第 1 页时点击"上一页"按钮,或在最后一页时点击"下一页"按钮,虽然按钮视觉上呈灰色/禁用态,但仍会向父组件 `emit('page-change', 0)` 或 `emit('page-change', totalPage+1)`,导致 `fetchUsers(0, ps)` 被调用、产生 `offset=-ps`(负数),请求异常。 - **现象**: 在第 1 页时点击"上一页"按钮,或在最后一页时点击"下一页"按钮,虽然按钮视觉上呈灰色/禁用态,但仍会向父组件 `emit('page-change', 0)` 或 `emit('page-change', totalPage+1)`,导致 `fetchUsers(0, ps)` 被调用、产生 `offset=-ps`(负数),请求异常。
- **根本原因**: `disabled` class 仅控制 CSS 样式(`opacity`、`cursor: not-allowed`),没有在事件处理函数中加入边界检查,点击事件照常触发和冒泡。 - **根本原因**: `disabled` class 仅控制 CSS 样式(`opacity`、`cursor: not-allowed`),没有在事件处理函数中加入边界检查,点击事件照常触发和冒泡。
- **修复方案**: - **修复方案**:
```typescript ```typescript
// CommonPagination.uvue — onPageBtnClick // CommonPagination.uvue — onPageBtnClick
// ❌ 修复前:直接 emit无边界保护 // ❌ 修复前:直接 emit无边界保护
const onPageBtnClick = (p: number) => { const onPageBtnClick = (p: number) => {
if (p !== -1) emit('page-change', p) if (p !== -1) emit("page-change", p);
} };
// ✅ 修复后:加入 p >= 1 && p <= props.totalPage 双向边界检查 // ✅ 修复后:加入 p >= 1 && p <= props.totalPage 双向边界检查
const onPageBtnClick = (p: number) => { const onPageBtnClick = (p: number) => {
if (p !== -1 && p >= 1 && p <= props.totalPage) { if (p !== -1 && p >= 1 && p <= props.totalPage) {
emit('page-change', p) emit("page-change", p);
} }
} };
``` ```
- **父页面的双重保护(纵深防御)**: - **父页面的双重保护(纵深防御)**:
```typescript ```typescript
// 在 handlePageChange 中同样加入边界检查,防止其他调用路径绕过组件保护 // 在 handlePageChange 中同样加入边界检查,防止其他调用路径绕过组件保护
const handlePageChange = (p: number) => { const handlePageChange = (p: number) => {
if (p < 1 || p > totalPage.value) return if (p < 1 || p > totalPage.value) return;
currentPage.value = p currentPage.value = p;
fetchUsers(p, pageSize.value) fetchUsers(p, pageSize.value);
} };
``` ```
- **推广规则**: - **推广规则**:
1. 任何分页组件,`disabled` 状态必须同时在 CSS **和**事件处理函数两层保护,视觉禁用 ≠ 逻辑禁用。 1. 任何分页组件,`disabled` 状态必须同时在 CSS **和**事件处理函数两层保护,视觉禁用 ≠ 逻辑禁用。

View File

@@ -1,326 +0,0 @@
<template>
<view class="finance-balance-record">
<!-- 筛选卡片 -->
<view class="filter-card border-shadow">
<view class="filter-row">
<view class="filter-item search-wrap">
<text class="filter-label">流水搜索:</text>
<input class="search-input" v-model="searchKeyword" placeholder="订单号/昵称/电话/用户ID" @confirm="handleQuery" />
</view>
<view class="filter-item">
<text class="filter-label">交易类型:</text>
<uni-data-select v-model="typeValue" :localdata="typeOptions" class="data-select" @change="handleQuery" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="btn-txt">查询</text>
</view>
</view>
</view>
<!-- 列表表格 -->
<view class="table-container border-shadow">
<view class="table-header">
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-order"><text class="th-txt">关联单据</text></view>
<view class="th col-time"><text class="th-txt">交易时间</text></view>
<view class="th col-amount"><text class="th-txt">交易金额</text></view>
<view class="th col-user"><text class="th-txt">用户</text></view>
<view class="th col-type"><text class="th-txt">业务类型</text></view>
<view class="th col-remark"><text class="th-txt">备注</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="tableData.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无记录</text>
</view>
<view class="table-row" v-for="(item, index) in tableData" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-order text-left"><text class="td-txt">{{ item.link_id || '-' }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.created_at.substring(0, 16).replace('T', ' ') }}</text></view>
<view class="td col-amount">
<text :class="['td-txt', item.pm === 1 ? 'red-txt' : 'green-txt']">
{{ item.pm === 1 ? '+' : '-' }}{{ item.number.toFixed(2) }}
</text>
</view>
<view class="td col-user">
<view class="u-info-box">
<text class="u-name">{{ item.user_name || '未知' }}</text>
<text class="u-id">UID:{{ item.uid.substring(0, 8) }}</text>
</view>
</view>
<view class="td col-type"><text class="td-txt">{{ getBillTypeText(item.type) }}</text></view>
<view class="td col-remark text-left"><text class="td-txt">{{ item.mark || '-' }}</text></view>
<view class="td col-op">
<text class="btn-link">详情</text>
</view>
</view>
</view>
</view>
<!-- 分页区域 -->
<view class="pagination-footer">
<view class="page-total">
<text class="total-txt">共 {{ total }} 条</text>
</view>
<view class="page-btns">
<view class="p-btn" :class="{ disabled: page <= 1 }" @click="prevPage">
<text><</text>
</view>
<view class="p-btn active">
<text>{{ page }}</text>
</view>
<view class="p-btn" :class="{ disabled: page >= totalPages }" @click="nextPage">
<text>></text>
</view>
</view>
<view class="page-jump">
<text class="jump-txt">前往</text>
<input class="jump-input" v-model="jumpPage" type="number" @confirm="goToJumpPage" />
<text class="jump-txt">页</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { fetchUserBillList } from '@/services/admin/financeService.uts'
import { UserBillRecord } from '@/types/admin/finance.uts'
const typeValue = ref<string>('all')
const searchKeyword = ref('')
const page = ref(1)
const pageSize = ref(15)
const total = ref(0)
const loading = ref(false)
const jumpPage = ref('')
const typeOptions = [
{ value: 'all', text: '全部类型' },
{ value: 'recharge', text: '充值' },
{ value: 'extract', text: '提现' },
{ value: 'pay', text: '支付' },
{ value: 'refund', text: '退款' },
{ value: 'system_add', text: '系统增加' },
{ value: 'system_sub', text: '系统减少' }
]
const tableData = ref<UserBillRecord[]>([])
const totalPages = computed(() : number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
async function loadList() {
loading.value = true
try {
const type = typeValue.value == 'all' ? null : typeValue.value
const res = await fetchUserBillList(
page.value,
pageSize.value,
'balance', // 余额明细
type,
null,
null,
null,
searchKeyword.value || null
)
tableData.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载余额记录失败', icon: 'none' })
} finally {
loading.value = false
}
}
onMounted(() => {
loadList()
})
const handleQuery = () => {
page.value = 1
loadList()
}
const prevPage = () => {
if (page.value > 1) {
page.value--
loadList()
}
}
const nextPage = () => {
if (page.value < totalPages.value) {
page.value++
loadList()
}
}
const goToJumpPage = () => {
const targetPage = parseInt(jumpPage.value)
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages.value) {
page.value = targetPage
loadList()
jumpPage.value = ''
} else {
uni.showToast({ title: '页码无效', icon: 'none' })
}
}
function getBillTypeText(type : string) : string {
const found = typeOptions.find(opt => opt.value === type)
return found != null ? found.text : type
}
</script>
<style scoped lang="scss">
.finance-balance-record {
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: 24px; margin-bottom: 20px; }
.filter-row { display: flex; flex-direction: row; align-items: center; }
.filter-item { display: flex; flex-direction: row; align-items: center; margin-right: 28px; }
.filter-label { font-size: 14px; color: #333; margin-right: 12px; white-space: nowrap; }
.search-wrap { flex: 1; }
.search-input { flex: 1; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 13px; }
.data-select { width: 160px; }
.btn-query {
background-color: #1890ff;
border-radius: 4px;
height: 32px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 10px;
cursor: pointer;
}
.btn-txt { color: #fff; font-size: 14px; }
/* 表格样式 */
.table-container {
display: flex;
flex-direction: column;
}
.table-header {
background-color: #e6f0ff;
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
}
.th {
padding: 12px 10px;
display: flex;
align-items: center;
justify-content: center;
}
.th-txt {
font-size: 13px;
font-weight: 600;
color: #303133;
}
.table-body {
display: flex;
flex-direction: column;
}
.table-row {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
min-height: 60px;
}
.table-row:hover {
background-color: #f9f9f9;
}
.td {
padding: 12px 10px;
display: flex;
align-items: center;
justify-content: center;
}
.td-txt {
font-size: 13px;
color: #606266;
}
.u-info-box {
display: flex;
flex-direction: column;
align-items: flex-start;
.u-name { font-size: 13px; color: #303133; font-weight: 500; }
.u-id { font-size: 11px; color: #909399; }
}
/* 列宽分配 */
.col-id { width: 70px; }
.col-order { width: 200px; justify-content: flex-start; }
.col-time { width: 160px; }
.col-amount { width: 120px; }
.col-user { width: 160px; }
.col-type { width: 140px; }
.col-remark { flex: 1; min-width: 180px; justify-content: flex-start; }
.col-op { width: 80px; }
.text-left { justify-content: flex-start; text-align: left; }
/* 颜色 */
.red-txt { color: #f5222d; font-weight: bold; }
.green-txt { color: #52c41a; font-weight: bold; }
.btn-link { color: #1890ff; font-size: 13px; cursor: pointer; }
/* 分页 */
.pagination-footer {
padding: 24px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.total-txt { font-size: 14px; color: #606266; }
.page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
}
.p-btn.active { background-color: #2d8cf0; border-color: #2d8cf0; color: #fff; }
.p-btn.disabled { color: #c0c4cc; background-color: #f5f7fa; cursor: not-allowed; }
.page-jump { display: flex; flex-direction: row; align-items: center; gap: 8px; }
.jump-txt { font-size: 14px; color: #606266; }
.jump-input { width: 40px; height: 32px; border: 1px solid #dcdfe6; text-align: center; border-radius: 4px; font-size: 14px; }
</style>

View File

@@ -1,286 +0,0 @@
<template>
<view class="finance-commission">
<!-- 筛选卡片 -->
<view class="filter-card border-shadow">
<view class="filter-row">
<view class="filter-item search-wrap">
<text class="filter-label">佣金搜索:</text>
<input class="search-input" v-model="searchKeyword" placeholder="关联单据/用户昵称/UID" @confirm="handleQuery" />
</view>
<view class="filter-item">
<text class="filter-label">收支类型:</text>
<uni-data-select v-model="pmValue" :localdata="pmOptions" class="data-select" @change="handleQuery" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="btn-txt">查询</text>
</view>
</view>
</view>
<!-- 列表表格 -->
<view class="table-card border-shadow">
<view class="action-bar">
<view class="btn-export">
<text class="export-txt">导出</text>
</view>
</view>
<view class="table-container">
<view class="table-header">
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-order"><text class="th-txt">关联单据</text></view>
<view class="th col-time"><text class="th-txt">时间</text></view>
<view class="th col-amount"><text class="th-txt">变动金额</text></view>
<view class="th col-user"><text class="th-txt">用户信息</text></view>
<view class="th col-type"><text class="th-txt">业务类型</text></view>
<view class="th col-remark"><text class="th-txt">备注</text></view>
</view>
<view class="table-body">
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="tableData.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 tableData" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-order"><text class="td-txt">{{ item.link_id || '-' }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.created_at.substring(0, 16).replace('T', ' ') }}</text></view>
<view class="td col-amount">
<text :class="['td-txt', item.pm === 1 ? 'red-txt' : 'green-txt']">
{{ item.pm === 1 ? '+' : '-' }}{{ item.number.toFixed(2) }}
</text>
</view>
<view class="td col-user">
<view class="u-info-box">
<text class="u-name">{{ item.user_name || '未知' }}</text>
<text class="u-id">UID:{{ item.uid.substring(0, 8) }}</text>
</view>
</view>
<view class="td col-type"><text class="td-txt">{{ getBillTypeText(item.type) }}</text></view>
<view class="td col-remark"><text class="td-txt">{{ item.mark || '-' }}</text></view>
</view>
</view>
</view>
</view>
<!-- 分页区域 -->
<view class="pagination-footer">
<view class="page-total">
<text class="total-txt">共 {{ total }} 条</text>
</view>
<view class="page-btns">
<view class="p-btn" :class="{ disabled: page <= 1 }" @click="prevPage">
<text><</text>
</view>
<view class="p-btn active">
<text>{{ page }}</text>
</view>
<view class="p-btn" :class="{ disabled: page >= totalPages }" @click="nextPage">
<text>></text>
</view>
</view>
<view class="page-jump">
<text class="jump-txt">前往</text>
<input class="jump-input" v-model="jumpPage" type="number" @confirm="goToJumpPage" />
<text class="jump-txt">页</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { fetchUserBillList } from '@/services/admin/financeService.uts'
import { UserBillRecord } from '@/types/admin/finance.uts'
const pmValue = ref<string>('all')
const searchKeyword = ref('')
const page = ref(1)
const pageSize = ref(15)
const total = ref(0)
const loading = ref(false)
const jumpPage = ref('')
const pmOptions = [
{ value: 'all', text: '全部收支' },
{ value: '1', text: '获得佣金' },
{ value: '0', text: '佣金支出' }
]
const tableData = ref<UserBillRecord[]>([])
const totalPages = computed(() : number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
async function loadList() {
loading.value = true
try {
const pm = pmValue.value == 'all' ? null : parseInt(pmValue.value)
const res = await fetchUserBillList(
page.value,
pageSize.value,
'brokerage', // 佣金明细
null,
pm,
null,
null,
searchKeyword.value || null
)
tableData.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载佣金记录失败', icon: 'none' })
} finally {
loading.value = false
}
}
onMounted(() => {
loadList()
})
const handleQuery = () => {
page.value = 1
loadList()
}
const prevPage = () => {
if (page.value > 1) {
page.value--
loadList()
}
}
const nextPage = () => {
if (page.value < totalPages.value) {
page.value++
loadList()
}
}
const goToJumpPage = () => {
const targetPage = parseInt(jumpPage.value)
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages.value) {
page.value = targetPage
loadList()
jumpPage.value = ''
} else {
uni.showToast({ title: '页码无效', icon: 'none' })
}
}
function getBillTypeText(type : string) : string {
if (type == 'extract') return '提现扣除'
if (type == 'brokerage') return '分销返佣'
if (type == 'refund') return '退款扣除'
return type
}
</script>
<style scoped lang="scss">
.finance-commission {
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: 24px; margin-bottom: 20px; }
.filter-row { display: flex; flex-direction: row; align-items: center; }
.filter-item { display: flex; flex-direction: row; align-items: center; margin-right: 28px; }
.filter-label { font-size: 14px; color: #333; margin-right: 12px; white-space: nowrap; }
.search-wrap { flex: 1; }
.search-input { flex: 1; height: 36px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.data-select { width: 160px; }
.btn-query {
background-color: #1890ff;
border-radius: 4px;
height: 36px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 10px;
cursor: pointer;
}
.btn-txt { color: #fff; font-size: 14px; }
/* 表格区域 */
.table-card { padding: 0; }
.action-bar { padding: 15px 20px; }
.btn-export { width: 60px; height: 32px; background-color: #e6f7ff; border: 1px solid #91d5ff; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.export-txt { color: #1890ff; font-size: 14px; }
.table-container { display: flex; flex-direction: column; }
.table-header { background-color: #e6f0ff; display: flex; flex-direction: row; }
.th { padding: 14px 10px; display: flex; align-items: center; justify-content: center; }
.th-txt { font-size: 13px; font-weight: 600; color: #303133; }
.table-body { display: flex; flex-direction: column; }
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 60px; }
.table-row:hover { background-color: #f9f9f9; }
.td { padding: 12px 10px; display: flex; align-items: center; justify-content: center; }
.td-txt { font-size: 13px; color: #606266; }
.u-info-box {
display: flex;
flex-direction: column;
align-items: flex-start;
.u-name { font-size: 13px; color: #303133; font-weight: 500; }
.u-id { font-size: 11px; color: #909399; }
}
.red-txt { color: #f5222d; font-weight: bold; }
.green-txt { color: #52c41a; font-weight: bold; }
/* 列宽分配 */
.col-id { width: 70px; }
.col-order { width: 180px; }
.col-time { width: 160px; }
.col-amount { width: 120px; }
.col-user { width: 180px; justify-content: flex-start; }
.col-type { width: 120px; }
.col-remark { flex: 1; min-width: 150px; justify-content: flex-start; }
/* 分页 */
.pagination-footer {
padding: 24px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.total-txt { font-size: 14px; color: #606266; }
.page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
}
.p-btn.active { background-color: #2d8cf0; border-color: #2d8cf0; color: #fff; }
.p-btn.disabled { color: #c0c4cc; background-color: #f5f7fa; cursor: not-allowed; }
.page-jump { display: flex; flex-direction: row; align-items: center; gap: 8px; }
.jump-txt { font-size: 14px; color: #606266; }
.jump-input { width: 40px; height: 32px; border: 1px solid #dcdfe6; text-align: center; border-radius: 4px; font-size: 14px; }
</style>

View File

@@ -201,8 +201,10 @@ async function loadData() {
const endTime = new Date().toISOString() const endTime = new Date().toISOString()
const startTime = getStartTime() const startTime = getStartTime()
// 各区块独立 try-catch单个接口失败不影响其他数据展示
// 1. 获取财务概况
try { try {
// 1. 获取财务概况
const finRes = await fetchFinanceOverview(startTime, endTime) const finRes = await fetchFinanceOverview(startTime, endTime)
if (finRes != null) { if (finRes != null) {
stats.rechargeAmount = finRes.recharge_amount.toFixed(2) stats.rechargeAmount = finRes.recharge_amount.toFixed(2)
@@ -212,8 +214,12 @@ async function loadData() {
stats.balancePay = finRes.total_user_balance.toFixed(2) stats.balancePay = finRes.total_user_balance.toFixed(2)
stats.commissionPay = finRes.total_user_brokerage.toFixed(2) stats.commissionPay = finRes.total_user_brokerage.toFixed(2)
} }
} catch (e) {
console.error('[finance-stats] 财务概况加载失败', e)
}
// 2. 获取订单统计 // 2. 获取订单统计(与订单统计页共用同一 RPC此处独立请求
try {
const orderRes = await fetchOrderStats(startTime, endTime) const orderRes = await fetchOrderStats(startTime, endTime)
if (orderRes != null) { if (orderRes != null) {
stats.revenue = orderRes.total_amount.toFixed(2) stats.revenue = orderRes.total_amount.toFixed(2)
@@ -221,15 +227,19 @@ async function loadData() {
stats.orderCount = String(orderRes.order_count) stats.orderCount = String(orderRes.order_count)
stats.refundAmount = orderRes.refund_amount.toFixed(2) stats.refundAmount = orderRes.refund_amount.toFixed(2)
} }
} catch (e) {
console.error('[finance-stats] 订单统计加载失败', e)
}
// 3. 获取趋势数据驱动图表 // 3. 获取趋势数据驱动图表
try {
const trendRes = await fetchFinanceBillSummary(startTime, endTime, 'day') const trendRes = await fetchFinanceBillSummary(startTime, endTime, 'day')
updateChart(trendRes) updateChart(trendRes)
} catch (e) { } catch (e) {
uni.showToast({ title: '加载统计失败', icon: 'none' }) console.error('[finance-stats] 趋势图加载失败', e)
} finally {
loading.value = false
} }
loading.value = false
} }
function handleDateTabChange(index : number) { function handleDateTabChange(index : number) {

View File

@@ -1,42 +0,0 @@
<template>
<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 { onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
onMounted(() => {
// 跳转到最新的订单管理页面
openRoute('OrderList')
})
</script>
<style scoped lang="scss">
.page-container {
padding: 20px;
min-height: 100vh;
background: #f5f5f5;
}
.page-content {
background: #fff;
border-radius: 4px;
padding: 24px;
}
.placeholder-card {
text-align: center;
padding: 60px 20px;
}
.placeholder-title {
display: block;
font-size: 18px;
font-weight: 600;
color: #666;
}
</style>

View File

@@ -1,124 +1,125 @@
<template> <template>
<AdminLayout :currentPage="currentPage"> <view class="admin-page">
<view class="order-statistic-page"> <view class="admin-sections">
<!-- 时间选择卡片 -->
<view class="filter-card">
<view class="filter-item">
<text class="filter-label">时间选择:</text>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
</view>
<!-- 数据汇总卡片 --> <!-- 时间选择卡片 -->
<view class="stat-cards-row"> <view class="admin-card filter-section">
<!-- 订单量 --> <view class="filter-item">
<view class="stat-card"> <text class="filter-label">时间选择:</text>
<view class="icon-wrap blue-bg"> <AnalyticsDateRangePicker
<view class="custom-icon icon-order"></view> :initialStartDate="startDate"
</view> :initialEndDate="endDate"
<view class="stat-info"> @apply="onApplyRange"
<text class="stat-value">{{ orderStats?.order_count ?? 0 }}</text> @clear="onClearRange"
<text class="stat-desc">订单量</text> />
</view>
</view>
<!-- 订单销售额 -->
<view class="stat-card">
<view class="icon-wrap orange-bg">
<view class="custom-icon icon-money"></view>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.total_amount?.toFixed(2) ?? '0.00' }}</text>
<text class="stat-desc">订单销售额</text>
</view>
</view>
<!-- 退款订单数 -->
<view class="stat-card">
<view class="icon-wrap green-bg">
<view class="custom-icon icon-refund"></view>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.refund_count ?? 0 }}</text>
<text class="stat-desc">退款订单数</text>
</view>
</view>
<!-- 退款金额 -->
<view class="stat-card last-card">
<view class="icon-wrap pink-bg">
<view class="custom-icon icon-refund-money"></view>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.refund_amount?.toFixed(2) ?? '0.00' }}</text>
<text class="stat-desc">退款金额</text>
</view>
</view>
</view>
<!-- 营业趋势图表 -->
<view class="chart-card">
<view class="card-header">
<text class="card-title">营业趋势</text>
</view>
<view class="chart-container">
<EChartsView :option="trendOption" class="trend-chart" />
</view>
</view>
<!-- 底部双图表区域 -->
<view class="bottom-charts-row">
<!-- 订单来源分析 -->
<view class="bottom-chart-card">
<view class="card-header-row">
<text class="card-title">订单来源分析</text>
<view class="style-toggle">
<text class="toggle-text">切换样式</text>
</view> </view>
</view> </view>
<view class="pie-chart-container">
<EChartsView :option="sourceOption" class="source-chart" />
</view>
</view>
<!-- 订单类型分析 --> <!-- 数据汇总卡片 (响应式 kpi-grid 4列) -->
<view class="bottom-chart-card"> <view class="kpi-grid">
<view class="card-header-row"> <!-- 订单量 -->
<text class="card-title">订单类型分析</text> <view class="admin-card stat-card">
<view class="style-toggle"> <view class="icon-wrap blue-bg">
<text class="toggle-text">切换样式</text> <text class="icon-char">≡</text>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.order_count ?? 0 }}</text>
<text class="stat-desc">订单量</text>
</view>
</view>
<!-- 订单销售额 -->
<view class="admin-card stat-card">
<view class="icon-wrap orange-bg">
<text class="icon-char">¥</text>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.total_amount?.toFixed(2) ?? '0.00' }}</text>
<text class="stat-desc">订单销售额</text>
</view>
</view>
<!-- 退款订单数 -->
<view class="admin-card stat-card">
<view class="icon-wrap green-bg">
<text class="icon-char">↩</text>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.refund_count ?? 0 }}</text>
<text class="stat-desc">退款订单数</text>
</view>
</view>
<!-- 退款金额 -->
<view class="admin-card stat-card">
<view class="icon-wrap pink-bg">
<text class="icon-char">↺</text>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.refund_amount?.toFixed(2) ?? '0.00' }}</text>
<text class="stat-desc">退款金额</text>
</view>
</view> </view>
</view> </view>
<view class="type-table-container">
<view class="table-header"> <!-- 营业趋势图表 -->
<text class="th-text col-id">序号</text> <view class="admin-card chart-card">
<text class="th-text col-name">来源</text> <view class="chart-card-header">
<text class="th-text col-money">金额</text> <text class="card-title">营业趋势</text>
<text class="th-text col-rate">占比率</text> <text class="download-icon-text">↓</text>
</view> </view>
<view class="table-body"> <EChartsView :option="trendOption" class="trend-chart" />
<view v-for="(item, index) in orderTypeData" :key="index" class="table-row"> </view>
<text class="td-text col-id">{{ index + 1 }}</text>
<text class="td-text col-name">{{ item.name }}</text> <!-- 底部双图表区域 -->
<text class="td-text col-money">{{ item.amount }}</text> <view class="bottom-charts-grid">
<view class="col-rate rate-box"> <!-- 订单来源分析 -->
<view class="progress-wrap"> <view class="admin-card bottom-chart-card">
<view class="progress-bar" :style="{ width: item.rate + '%', backgroundColor: '#1890ff' }"></view> <view class="card-header-row">
<text class="card-title">订单来源分析</text>
<view class="style-toggle">
<text class="toggle-text">切换样式</text>
</view>
</view>
<view class="pie-chart-container">
<EChartsView :option="sourceOption" class="source-chart" />
</view>
</view>
<!-- 订单类型分析 -->
<view class="admin-card bottom-chart-card">
<view class="card-header-row">
<text class="card-title">订单类型分析</text>
<view class="style-toggle">
<text class="toggle-text">切换样式</text>
</view>
</view>
<view class="type-table-container">
<view class="table-header">
<text class="th-text col-id">序号</text>
<text class="th-text col-name">来源</text>
<text class="th-text col-money">金额</text>
<text class="th-text col-rate">占比率</text>
</view>
<view class="table-body">
<view v-for="(item, index) in orderTypeData" :key="index" class="table-row">
<text class="td-text col-id">{{ index + 1 }}</text>
<text class="td-text col-name">{{ item.name }}</text>
<text class="td-text col-money">{{ item.amount }}</text>
<view class="col-rate rate-box">
<view class="progress-wrap">
<view class="progress-bar" :style="{ width: item.rate + '%', backgroundColor: '#1890ff' }"></view>
</view>
<text class="rate-val">{{ item.rate }}%</text>
</view>
</view> </view>
<text class="rate-val">{{ item.rate }}%</text>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</view>
</AdminLayout>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
@@ -173,23 +174,37 @@ async function loadAllData() {
const st = startDate.value ? (startDate.value + ' 00:00:00') : 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() const et = endDate.value ? (endDate.value + ' 23:59:59') : new Date().toISOString()
// 各图表独立 try-catch单个接口失败不影响其他图表展示
// 1. 加载汇总数据
try { try {
// 1. 加载汇总数据
orderStats.value = await fetchOrderStats(st, et) orderStats.value = await fetchOrderStats(st, et)
} catch (e) {
// 2. 加载趋势数据 console.error('[order-stats] 汇总数据加载失败', e)
}
// 2. 加载趋势数据
try {
const trendData = await fetchOrderTrend(st, et) const trendData = await fetchOrderTrend(st, et)
initTrendChart(trendData) initTrendChart(trendData)
} catch (e) {
// 3. 加载来源数据 console.error('[order-stats] 趋势图加载失败', e)
}
// 3. 加载来源数据
try {
const sourceData = await fetchOrderSourceStats(st, et) const sourceData = await fetchOrderSourceStats(st, et)
initSourceChart(sourceData) initSourceChart(sourceData)
} catch (e) {
console.error('[order-stats] 来源图加载失败', e)
}
// 4. 加载订单类型数据 // 4. 加载订单类型数据rpc_admin_order_type_stats 目前返回 400单独隔离
try {
const typeData = await fetchOrderTypeStats(st, et) const typeData = await fetchOrderTypeStats(st, et)
orderTypeData.value = typeData orderTypeData.value = typeData
} catch (e) { } catch (e) {
uni.showToast({ title: '加载统计数据失败', icon: 'none' }) console.error('[order-stats] 订单类型数据加载失败', e)
} }
} }
@@ -340,97 +355,125 @@ function initTrendChart(data: any[]) {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.order-statistic-page { /* ===== 筛选区 ===== */
/* 使用 Layout 的背景和内边距 */ .filter-section {
min-height: 100%; /* admin-card 提供 background / padding / border-radius */
}
.filter-card {
background-color: #fff;
padding: var(--admin-card-padding);
border-radius: 4px;
margin-bottom: var(--admin-section-gap);
} }
.filter-item { .filter-item {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 12px;
} }
.filter-label { .filter-label {
font-size: 14px; font-size: 14px;
color: #333; color: #333;
margin-right: 12px; white-space: nowrap;
} }
.date-picker-mock { /* ===== KPI 统计卡片 ===== */
/* .kpi-grid 由 admin-responsive.css 全局提供响应式 grid>=1200px 4列 / >=768px 2列 / <768px 1列 */
.stat-card {
/* admin-card 提供背景/内边距,这里只定义内部 flex 布局 */
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
border: 1px solid #d9d9d9; min-width: 0;
border-radius: 4px;
padding: 4px 12px;
width: 240px;
} }
.calendar-icon { .icon-wrap {
width: 16px; width: 54px;
height: 16px; height: 54px;
margin-right: 8px; border-radius: 27px;
opacity: 0.45; display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
flex-shrink: 0;
} }
.date-range { .blue-bg { background-color: #1890ff; }
font-size: 14px; .orange-bg { background-color: #fa8c16; }
color: #595959; .green-bg { background-color: #52c41a; }
.pink-bg { background-color: #eb2f96; }
.stat-info {
display: flex;
flex-direction: column;
min-width: 0;
} }
.stat-cards-row { .stat-value {
font-size: 28px;
font-weight: 500;
color: #1a1a1a;
line-height: 1.2;
}
.stat-desc {
font-size: 13px;
color: #8c8c8c;
margin-top: 4px;
}
/* 自定义图标 */
.icon-char {
font-size: 20px;
color: #fff;
font-weight: bold;
line-height: 1;
}
/* ===== 趋势图表 ===== */
.chart-card {
/* admin-card 提供背景/内边距 */
}
.chart-card-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--admin-section-gap); justify-content: space-between;
margin-bottom: var(--admin-section-gap); align-items: center;
} margin-bottom: 12px;
.chart-card {
background-color: #fff;
border-radius: 4px;
padding: var(--admin-card-padding);
}
.card-header {
margin-bottom: 20px;
} }
.card-title { .card-title {
font-size: 16px; font-size: 15px;
font-weight: bold; font-weight: 600;
color: #1a1a1a; color: #1a1a1a;
} }
.chart-container { .download-icon-text {
width: 100%; font-size: 18px;
height: 400px; color: #bfbfbf;
cursor: pointer;
padding: 4px 6px;
} }
.trend-chart { .trend-chart {
width: 100%; width: 100%;
height: 100%; height: 400px;
} }
.bottom-charts-row { /* ===== 底部双图表 ===== */
display: flex; .bottom-charts-grid {
flex-direction: row; display: grid;
gap: var(--admin-section-gap); grid-template-columns: 1fr 1fr;
margin-top: var(--admin-section-gap); gap: var(--admin-section-gap, 20px);
}
@media (max-width: 900px) {
.bottom-charts-grid {
grid-template-columns: 1fr;
}
} }
.bottom-chart-card { .bottom-chart-card {
flex: 1; /* admin-card 提供背景/内边距 */
background-color: #fff; min-width: 0;
border-radius: 4px;
padding: var(--admin-card-padding);
} }
.card-header-row { .card-header-row {
@@ -445,6 +488,7 @@ function initTrendChart(data: any[]) {
border: 1px solid #d9d9d9; border: 1px solid #d9d9d9;
border-radius: 4px; border-radius: 4px;
padding: 2px 8px; padding: 2px 8px;
cursor: pointer;
} }
.toggle-text { .toggle-text {
@@ -452,6 +496,7 @@ function initTrendChart(data: any[]) {
color: #595959; color: #595959;
} }
/* 来源饼图 */
.pie-chart-container { .pie-chart-container {
width: 100%; width: 100%;
height: 320px; height: 320px;
@@ -462,6 +507,7 @@ function initTrendChart(data: any[]) {
height: 100%; height: 100%;
} }
/* 类型分析表格 */
.type-table-container { .type-table-container {
width: 100%; width: 100%;
} }
@@ -479,18 +525,13 @@ function initTrendChart(data: any[]) {
color: #595959; color: #595959;
} }
/* 统一列宽与对齐方式 */
.col-id { width: 80px; text-align: center; }
.col-name { flex: 1; text-align: left; padding-left: 40px; }
.col-money { width: 180px; text-align: left; }
.col-rate { width: 240px; text-align: left; }
.table-row { .table-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: 16px 0; padding: 16px 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
&:last-child { border-bottom: none; }
} }
.td-text { .td-text {
@@ -498,20 +539,27 @@ function initTrendChart(data: any[]) {
color: #262626; color: #262626;
} }
/* 列宽 */
.col-id { width: 60px; text-align: center; flex-shrink: 0; }
.col-name { flex: 1; text-align: left; padding-left: 24px; min-width: 0; }
.col-money { width: 120px; text-align: left; flex-shrink: 0; }
.col-rate { width: 200px; text-align: left; flex-shrink: 0; }
.rate-box { .rate-box {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-start; /* 改为左对齐,与表头对齐样式一致 */ justify-content: flex-start;
} }
.progress-wrap { .progress-wrap {
width: 120px; width: 100px;
height: 6px; height: 6px;
background-color: #f5f5f5; background-color: #f5f5f5;
border-radius: 3px; border-radius: 3px;
margin-right: 12px; margin-right: 10px;
overflow: hidden; overflow: hidden;
flex-shrink: 0;
} }
.progress-bar { .progress-bar {
@@ -522,129 +570,19 @@ function initTrendChart(data: any[]) {
.rate-val { .rate-val {
font-size: 13px; font-size: 13px;
color: #595959; color: #595959;
width: 50px; min-width: 44px;
text-align: right; text-align: right;
} }
.stat-card {
flex: 1;
background-color: #fff;
border-radius: 4px;
padding: var(--admin-card-padding);
display: flex;
flex-direction: row;
align-items: center;
}
.icon-wrap {
width: 54px;
height: 54px;
border-radius: 27px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
}
/* 颜色背景 - 1:1 匹配截图 */
.blue-bg {
background-color: #e6f7ff;
border: 2px solid #bae7ff;
}
.orange-bg {
background-color: #fff7e6;
border: 2px solid #ffe58f;
}
.green-bg {
background-color: #f6ffed;
border: 2px solid #b7eb8f;
}
.pink-bg {
background-color: #fff0f6;
border: 2px solid #ffadd2;
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 28px;
font-weight: 500;
color: #1a1a1a;
line-height: 1.2;
}
.stat-desc {
font-size: 13px;
color: #8c8c8c;
margin-top: 4px;
}
/* 自定义图标 1:1 形状模拟 - 内部使用伪元素或形状模拟截图形状 */
.custom-icon {
width: 24px;
height: 24px;
position: relative;
}
.icon-order {
background-color: #1890ff;
border-radius: 4px;
&::before {
content: '';
position: absolute;
top: 6px; left: 4px; right: 4px; height: 2px;
background: #fff;
}
}
.icon-money {
background-color: #faad14;
border-radius: 50%;
&::after {
content: '¥';
color: #fff;
font-size: 12px;
font-weight: bold;
display: flex; justify-content: center; align-items: center; height: 100%;
}
}
.icon-refund {
background-color: #52c41a;
border-radius: 4px;
&::before {
content: '↺';
color: #fff;
font-size: 16px;
display: flex; justify-content: center; align-items: center; height: 100%;
}
}
.icon-refund-money {
background-color: #eb2f96;
border-radius: 50%;
&::after {
content: '';
color: #fff;
font-size: 18px;
display: flex; justify-content: center; align-items: center; height: 100%;
}
}
</style> </style>
<style scoped lang="scss"> <style scoped lang="scss">
@import '@/uni.scss'; @import '@/uni.scss';
.page { .page { }
/* 使用 Layout 的背景和内边距 */ .header {
} padding: var(--admin-card-padding);
.header { border-radius: $radius;
padding: var(--admin-card-padding); background: $background-primary;
border-radius: $radius; box-shadow: $shadow-xs;
background: $background-primary;
box-shadow: $shadow-xs;
} }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; } .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; } .sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }

View File

@@ -1,76 +0,0 @@
<template>
<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 { onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
onMounted(() => {
openRoute('product_productList')
})
</script>
<style scoped lang="scss">
.page-container {
padding: 20px;
min-height: 100vh;
background: #f5f5f5;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.page-subtitle {
display: block;
font-size: 14px;
color: #999;
}
.page-content {
background: #fff;
border-radius: 4px;
padding: 24px;
}
.placeholder-card {
text-align: center;
padding: 60px 20px;
}
.placeholder-title {
display: block;
font-size: 18px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
}
.placeholder-desc {
display: block;
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.placeholder-info {
display: block;
font-size: 12px;
color: #1890ff;
}
</style>

View File

@@ -173,8 +173,10 @@ async function loadAllData() {
const startTime = startDate.value ? (startDate.value + ' 00:00:00') : 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() const endTime = endDate.value ? (endDate.value + ' 23:59:59') : new Date().toISOString()
// 各图表独立 try-catch单个接口失败如 404不影响其他区块展示
// 1. 加载核心指标
try { try {
// 1. 加载核心指标
const stats = await fetchAdminProductStats(startTime, endTime) const stats = await fetchAdminProductStats(startTime, endTime)
if (stats != null) { if (stats != null) {
statItems.value.forEach(item => { statItems.value.forEach(item => {
@@ -182,16 +184,24 @@ async function loadAllData() {
item.value = typeof val === 'number' ? (item.key.includes('amount') ? val.toFixed(2) : String(val)) : String(val ?? '0') item.value = typeof val === 'number' ? (item.key.includes('amount') ? val.toFixed(2) : String(val)) : String(val ?? '0')
}) })
} }
} catch (e) {
console.error('[product-stats] 核心指标加载失败', e)
}
// 2. 加载趋势图 // 2. 加载趋势图rpc_admin_product_trend 404 时返回空数组,图表为空但不崩溃)
try {
const trendData = await fetchAdminProductTrend(startTime, endTime) const trendData = await fetchAdminProductTrend(startTime, endTime)
initChart(trendData) initChart(trendData)
} catch (e) {
console.error('[product-stats] 趋势图加载失败', e)
}
// 3. 加载排行 // 3. 加载排行rpc_admin_product_ranking 404 时返回空数组)
try {
const rankingData = await fetchAdminProductRanking(startTime, endTime, 'sales', 10) const rankingData = await fetchAdminProductRanking(startTime, endTime, 'sales', 10)
rankingList.value = rankingData rankingList.value = rankingData
} catch (e) { } catch (e) {
uni.showToast({ title: '加载统计失败', icon: 'none' }) console.error('[product-stats] 排行加载失败', e)
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -1,561 +0,0 @@
<template>
<AdminLayout currentPage="service-autoReply">
<view class="service-container">
<view class="content-card">
<view class="header-row">
<view class="header-title">自动回复管理</view>
<view class="header-actions">
<button class="btn-primary" @click="handleCreate">新增自动回复</button>
</view>
</view>
<view class="filter-section">
<view class="filter-row">
<view class="filter-item">
<text class="filter-label">关键词</text>
<input v-model="searchKeyword" class="filter-input" placeholder="请输入关键词" />
</view>
<view class="filter-item">
<text class="filter-label">状态</text>
<picker v-model="filterStatus" class="picker-wrapper" :range="statusOptions" range-key="label">
<view>{{ filterStatus > -1 ? statusOptions[filterStatus].label : '全部' }}</view>
</picker>
</view>
<button class="btn-search" @click="handleSearch">搜索</button>
<button class="btn-reset" @click="handleReset">重置</button>
</view>
<view v-if="selectedIds.length > 0" class="batch-actions">
<text class="batch-text">已选择 {{ selectedIds.length }} 条</text>
<button class="btn-batch-delete" @click="handleBatchDelete">批量删除</button>
<button class="btn-cancel-select" @click="handleCancelSelect">取消选择</button>
</view>
</view>
<view class="table">
<view class="table-header">
<view class="cell cell-checkbox"><input type="checkbox" :checked="selectAll" @change="handleSelectAll"></view>
<view class="cell cell-id">ID</view>
<view class="cell cell-keyword">关键词</view>
<view class="cell cell-reply">回复内容</view>
<view class="cell cell-status">状态</view>
<view class="cell cell-time">更新时间</view>
<view class="cell cell-actions">操作</view>
</view>
<view v-if="list.length === 0" class="empty">暂无数据</view>
<view v-for="item in list" :key="item.id" class="table-row" :class="{ selected: selectedIds.includes(item.id) }">
<view class="cell cell-checkbox"><input type="checkbox" :checked="selectedIds.includes(item.id)" @change="() => handleSelectItem(item.id)"></view>
<view class="cell cell-id">{{ item.id }}</view>
<view class="cell cell-keyword">{{ item.keyword }}</view>
<view class="cell cell-reply">{{ item.reply }}</view>
<view class="cell cell-status">
<text class="badge" :class="{ on: item.status === 1 }">{{ item.status === 1 ? '启用' : '禁用' }}</text>
</view>
<view class="cell cell-time">{{ item.updated_at }}</view>
<view class="cell cell-actions">
<button class="btn-action" @click="handleEdit(item.id)">编辑</button>
<button class="btn-action btn-danger" @click="handleDelete(item.id)">删除</button>
</view>
</view>
</view>
<view class="pagination">
<button class="btn-page" :disabled="page === 1" @click="prevPage">上一页</button>
<text class="page-text">第 {{ page }} 页</text>
<button class="btn-page" :disabled="page >= totalPage" @click="nextPage">下一页</button>
</view>
<!-- 编辑/新增自动回复 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ editingId ? '编辑自动回复' : '新增自动回复' }}</text>
<button class="modal-close" @click="closeModal">关闭</button>
</view>
<view class="modal-body">
<view class="form-group">
<label class="form-label">关键词<text class="required">*</text></label>
<input v-model="form.keyword" class="form-input" placeholder="请输入关键词" />
</view>
<view class="form-group">
<label class="form-label">回复内容 <text class="required">*</text></label>
<textarea v-model="form.reply" class="form-textarea" placeholder="请输入回复内容"></textarea>
</view>
<view class="form-group">
<label class="form-label">状态</label>
<picker v-model="form.status" class="picker-wrapper" :range="statusOptions" range-key="label">
<view>{{ statusOptions[form.status].label }}</view>
</picker>
</view>
</view>
<view class="modal-footer">
<button class="btn-cancel" @click="closeModal">取消</button>
<button class="btn-confirm" @click="handleSave">保存</button>
</view>
</view>
</view>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
import { getAutoReplyList, saveAutoReply, deleteAutoReply } from './service.uts'
const list = ref<any[]>([])
const page = ref<number>(1)
const pageSize = 10
const total = ref<number>(0)
const selectedIds = ref<number[]>([])
const selectAll = ref<boolean>(false)
const showModal = ref<boolean>(false)
const editingId = ref<number | null>(null)
const searchKeyword = ref<string>('')
const filterStatus = ref<number>(-1)
const form = ref<any>({
keyword: '',
reply: '',
status: 1
})
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 }
]
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const loadList = async () => {
const res = await getAutoReplyList({
page: page.value,
limit: pageSize,
keyword: searchKeyword.value,
status: filterStatus.value > -1 ? filterStatus.value : undefined
})
list.value = res.items
total.value = res.total
selectedIds.value = []
selectAll.value = false
}
const handleSelectAll = () => {
selectAll.value = !selectAll.value
if (selectAll.value) {
selectedIds.value = list.value.map(item => item.id)
} else {
selectedIds.value = []
}
}
const handleSelectItem = (id: number) => {
const index = selectedIds.value.indexOf(id)
if (index > -1) {
selectedIds.value.splice(index, 1)
} else {
selectedIds.value.push(id)
}
selectAll.value = selectedIds.value.length === list.value.length && list.value.length > 0
}
const handleCancelSelect = () => {
selectedIds.value = []
selectAll.value = false
}
const handleCreate = () => {
editingId.value = null
form.value = { keyword: '', reply: '', status: 1 }
showModal.value = true
}
const handleEdit = (id: number) => {
const item = list.value.find(i => i.id === id)
if (item) {
editingId.value = id
form.value = { keyword: item.keyword, reply: item.reply, status: item.status }
showModal.value = true
}
}
const handleSave = async () => {
if (!form.value.keyword.trim()) {
uni.showToast({ title: '请输入关键词', icon: 'none' })
return
}
if (!form.value.reply.trim()) {
uni.showToast({ title: '请输入回复内容', icon: 'none' })
return
}
await saveAutoReply({
id: editingId.value,
...form.value
})
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
loadList()
}
const closeModal = () => {
showModal.value = false
editingId.value = null
form.value = { keyword: '', reply: '', status: 1 }
}
const handleBatchDelete = () => {
uni.showModal({
title: '批量删除',
content: `确认删除选中的 ${selectedIds.value.length} 条自动回复吗?`,
success: async (res) => {
if (res.confirm) {
for (const id of selectedIds.value) {
await deleteAutoReply(id)
}
uni.showToast({ title: '删除成功', icon: 'success' })
loadList()
}
}
})
}
const handleDelete = async (id: number) => {
uni.showModal({
title: '删除自动回复',
content: '确认删除该自动回复吗?',
success: async (res) => {
if (res.confirm) {
await deleteAutoReply(id)
uni.showToast({ title: '删除成功', icon: 'success' })
loadList()
}
}
})
}
const handleSearch = () => {
page.value = 1
loadList()
}
const handleReset = () => {
searchKeyword.value = ''
filterStatus.value = -1
page.value = 1
loadList()
}
const prevPage = () => {
if (page.value > 1) {
page.value--
loadList()
}
}
const nextPage = () => {
if (page.value < totalPage.value) {
page.value++
loadList()
}
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.service-container {
display: flex;
flex-direction: row;
width: 100%;
padding: $space-lg;
background: $background-secondary;
}
.content-card {
flex: 1;
background: #fff;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
padding: $space-lg;
}
.header-row {
display: flex;
flex-direction: row;
width: 100%;
justify-content: space-between;
align-items: center;
margin-bottom: $space-lg;
}
.header-title { font-size: $font-size-lg; font-weight: 600; color: $text-primary; }
.btn-primary { background: $primary-color; color: #fff; padding: $space-sm $space-lg; border-radius: $radius-sm; border: none; font-size: $font-size-sm; }
.filter-section {
margin-bottom: $space-lg;
}
.filter-row {
display: flex;
flex-direction: row;
width: 100%;
align-items: center;
gap: $space-md;
margin-bottom: $space-lg;
flex-wrap: wrap;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
gap: $space-sm;
}
.filter-label {
color: $text-secondary;
font-size: $font-size-sm;
min-width: 60px;
}
.picker-wrapper {
padding: $space-xs $space-sm;
border: 1px solid $border-color;
border-radius: $radius-sm;
}
.filter-input {
padding: $space-xs $space-sm;
border: 1px solid $border-color;
border-radius: $radius-sm;
flex: 1;
min-width: 200px;
}
.btn-search {
background: $primary-color;
color: #fff;
padding: $space-xs $space-lg;
border: none;
border-radius: $radius-sm;
font-size: $font-size-sm;
}
.btn-reset {
background: transparent;
color: $primary-color;
border: 1px solid $primary-color;
padding: $space-xs $space-lg;
border-radius: $radius-sm;
font-size: $font-size-sm;
}
.batch-actions {
display: flex;
flex-direction: row;
width: 100%;
align-items: center;
gap: $space-md;
padding: $space-md;
background: #fef3c7;
border-radius: $radius-sm;
margin-top: $space-md;
}
.batch-text {
font-size: $font-size-sm;
color: #92400e;
min-width: 100px;
}
.btn-batch-delete,
.btn-cancel-select {
padding: $space-xs $space-md;
border-radius: $radius-sm;
border: none;
font-size: $font-size-xs;
color: #fff;
}
.btn-batch-delete {
background: $error-color;
}
.btn-cancel-select {
background: #d1d5db;
color: #4b5563;
}
.table { border: 1px solid $border-color; border-radius: $radius-sm; overflow: hidden; }
.table-header,
.table-row {
display: flex;
flex-direction: row;
align-items: center;
}
.table-header { background: $background-tertiary; font-weight: 600; }
.table-row { border-top: 1px solid $border-color; }
.table-row.selected { background: #f3f4f6; }
.cell { padding: $space-sm $space-md; font-size: $font-size-sm; color: $text-primary; display: flex; align-items: center; }
.cell-checkbox { width: 50px; justify-content: center; }
.cell-id { width: 60px; justify-content: center; }
.cell-keyword { width: 140px; }
.cell-reply {
flex: 1;
flex-direction:row
}
.cell-status { width: 100px; justify-content: center; }
.cell-time { width: 180px; }
.cell-actions {
display: flex;
flex-direction:row;
width: 180px;
gap: $space-sm;
justify-content: center;
}
.badge { padding: 2px 10px; border-radius: $radius-sm; background: lighten($error-color, 40%); color: $error-color; font-size: $font-size-xs; }
.badge.on { background: lighten($success-color, 40%); color: $success-color; }
.btn-action { border: 1px solid $primary-color; color: $primary-color; background: transparent; padding: 4px 10px; border-radius: $radius-sm; font-size: $font-size-xs; }
.btn-action.btn-danger { border-color: $error-color; color: $error-color; }
.empty { padding: $space-xl; text-align: center; color: $text-tertiary; }
.pagination {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: $space-md;
margin-top: $space-lg;
}
.btn-page { padding: $space-xs $space-md; border: 1px solid $border-color; border-radius: $radius-sm; background: #fff; }
.page-text { color: $text-secondary; font-size: $font-size-sm; }
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: $radius-lg;
width: 90%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-lg;
border-bottom: 1px solid $border-color;
}
.modal-title {
font-size: $font-size-base;
font-weight: 600;
color: $text-primary;
}
.modal-close {
background: transparent;
border: none;
font-size: 28px;
color: $text-tertiary;
cursor: pointer;
}
.modal-body {
padding: $space-lg;
overflow-y: auto;
flex: 1;
}
.form-group {
margin-bottom: $space-lg;
display: flex;
flex-direction: column;
gap: $space-sm;
}
.form-label {
font-size: $font-size-sm;
font-weight: 500;
color: $text-primary;
}
.required {
color: $error-color;
}
.form-input {
padding: $space-sm;
border: 1px solid $border-color;
border-radius: $radius-sm;
font-size: $font-size-sm;
color: $text-primary;
background: $background-secondary;
}
.form-textarea {
padding: $space-sm;
border: 1px solid $border-color;
border-radius: $radius-sm;
font-size: $font-size-sm;
color: $text-primary;
background: $background-secondary;
min-height: 120px;
}
.modal-footer {
display: flex;
gap: $space-md;
justify-content: flex-end;
padding: $space-lg;
border-top: 1px solid $border-color;
background: $background-secondary;
}
.btn-cancel,
.btn-confirm {
padding: $space-sm $space-lg;
border-radius: $radius-sm;
border: none;
font-size: $font-size-sm;
cursor: pointer;
}
.btn-cancel {
background: $background-tertiary;
color: $text-primary;
}
.btn-confirm {
background: $primary-color;
color: #fff;
}
</style>

View File

@@ -1,99 +1,5 @@
main.uts:30 [Vue warn]: Unhandled error during execution of async component loader ak-req.uts:218 POST http://119.146.131.237:9126/rest/v1/rpc/rpc_admin_product_ranking 404 (Not Found)
at <AsyncComponentWrapper> ak-req.uts:218 POST http://119.146.131.237:9126/rest/v1/rpc/rpc_admin_product_trend 404 (Not Found)
at <PageBody> ak-req.uts:218 POST http://119.146.131.237:9126/rest/v1/rpc/rpc_admin_product_trend 404 (Not Found)
at <Page> [AkReq.request] headers: {"apikey":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890","Content-Type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmOTkyZGZmYS1hOGZkLTQ1YmItODY3MC02ZmVlNWE1YWU4NGQiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzczOTc4NDY1LCJpYXQiOjE3NzM5NzQ4NjUsImVtYWlsIjoiYWRtaW5AMTYzLmNvbSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnsiZW1haWwiOiJhZG1pbkAxNjMuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInBob25lX3ZlcmlmaWVkIjpmYWxzZSwic3ViIjoiZjk5MmRmZmEtYThmZC00NWJiLTg2NzAtNmZlZTVhNWFlODRkIiwidXNlcl9yb2xlIjoibWVyY2hhbnQifSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc3Mzk2NzY4Mn1dLCJzZXNzaW9uX2lkIjoiODhkMDhhNmEtNzBiOS00M2RhLWEwNmQtZWQ5ODIzZTM1MTQwIiwiaXNfYW5vbnltb3VzIjpmYWxzZX0.WitVmu_GTafuNQ5mxQbQPTmsEldjU0HA6qtCy0p6utM","Accept":"application/json"}
at <Anonymous> ak-req.uts:218 POST http://119.146.131.237:9126/rest/v1/rpc/rpc_admin_order_type_stats 400 (Bad Request)
at <KeepAlive>
at <RouterView>
at <Layout>
at <App>
warnHandler @ uni-h5.es.js:19975
callWithErrorHandling @ vue.runtime.esm.js:1381
warn$1 @ vue.runtime.esm.js:1207
logError @ vue.runtime.esm.js:1438
errorHandler @ uni-h5.es.js:19600
callWithErrorHandling @ vue.runtime.esm.js:1381
handleError @ vue.runtime.esm.js:1421
onError @ vue.runtime.esm.js:3724
(anonymous) @ vue.runtime.esm.js:3767
Promise.catch
setup @ vue.runtime.esm.js:3766
callWithErrorHandling @ vue.runtime.esm.js:1381
setupStatefulComponent @ vue.runtime.esm.js:8985
setupComponent @ vue.runtime.esm.js:8946
mountComponent @ vue.runtime.esm.js:7262
processComponent @ vue.runtime.esm.js:7228
patch @ vue.runtime.esm.js:6694
mountChildren @ vue.runtime.esm.js:6942
processFragment @ vue.runtime.esm.js:7158
patch @ vue.runtime.esm.js:6668
mountChildren @ vue.runtime.esm.js:6942
processFragment @ vue.runtime.esm.js:7158
patch @ vue.runtime.esm.js:6668
mountChildren @ vue.runtime.esm.js:6942
mountElement @ vue.runtime.esm.js:6849
processElement @ vue.runtime.esm.js:6814
patch @ vue.runtime.esm.js:6682
mountChildren @ vue.runtime.esm.js:6942
mountElement @ vue.runtime.esm.js:6849
processElement @ vue.runtime.esm.js:6814
patch @ vue.runtime.esm.js:6682
mountChildren @ vue.runtime.esm.js:6942
processFragment @ vue.runtime.esm.js:7158
patch @ vue.runtime.esm.js:6668
componentUpdateFn @ vue.runtime.esm.js:7372
run @ vue.runtime.esm.js:153
instance.update @ vue.runtime.esm.js:7497
setupRenderEffect @ vue.runtime.esm.js:7507
mountComponent @ vue.runtime.esm.js:7274
processComponent @ vue.runtime.esm.js:7228
patch @ vue.runtime.esm.js:6694
mountChildren @ vue.runtime.esm.js:6942
mountElement @ vue.runtime.esm.js:6849
processElement @ vue.runtime.esm.js:6814
patch @ vue.runtime.esm.js:6682
componentUpdateFn @ vue.runtime.esm.js:7372
run @ vue.runtime.esm.js:153
instance.update @ vue.runtime.esm.js:7497
setupRenderEffect @ vue.runtime.esm.js:7507
mountComponent @ vue.runtime.esm.js:7274
processComponent @ vue.runtime.esm.js:7228
patch @ vue.runtime.esm.js:6694
componentUpdateFn @ vue.runtime.esm.js:7372
run @ vue.runtime.esm.js:153
instance.update @ vue.runtime.esm.js:7497
setupRenderEffect @ vue.runtime.esm.js:7507
mountComponent @ vue.runtime.esm.js:7274
processComponent @ vue.runtime.esm.js:7228
patch @ vue.runtime.esm.js:6694
componentUpdateFn @ vue.runtime.esm.js:7453
run @ vue.runtime.esm.js:153
instance.update @ vue.runtime.esm.js:7497
updateComponent @ vue.runtime.esm.js:7305
processComponent @ vue.runtime.esm.js:7239
patch @ vue.runtime.esm.js:6694
componentUpdateFn @ vue.runtime.esm.js:7453
run @ vue.runtime.esm.js:153
instance.update @ vue.runtime.esm.js:7497
callWithErrorHandling @ vue.runtime.esm.js:1381
flushJobs @ vue.runtime.esm.js:1585
Promise.then
queueFlush @ vue.runtime.esm.js:1494
queueJob @ vue.runtime.esm.js:1488
scheduler @ vue.runtime.esm.js:3179
resetScheduling @ vue.runtime.esm.js:236
triggerEffects @ vue.runtime.esm.js:280
triggerRefValue @ vue.runtime.esm.js:1033
set value @ vue.runtime.esm.js:1078
finalizeNavigation @ vue-router.mjs?v=ed041164:2474
(anonymous) @ vue-router.mjs?v=ed041164:2384
Promise.then
pushWithRedirect @ vue-router.mjs?v=ed041164:2352
push @ vue-router.mjs?v=ed041164:2278
install @ vue-router.mjs?v=ed041164:2631
use @ vue.runtime.esm.js:5190
initRouter @ uni-h5.es.js:19886
install @ uni-h5.es.js:19955
use @ vue.runtime.esm.js:5190
(anonymous) @ main.uts:30
main.uts:30 SyntaxError: The requested module '/services/admin/financeService.uts?import' does not provide an export named 'fetchAdminBalanceDistribution' (at index.uvue:150:3)