fix: ui
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m38s

This commit is contained in:
luo
2026-08-24 09:37:36 +08:00
parent ecb594dbb6
commit 5216530714
23 changed files with 3608 additions and 2906 deletions

View File

@@ -1,24 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FundSummary } from '@/api/commission'
<template>
<view
v-if="variant === 'wallet'"
class="relative overflow-hidden rounded-16px from-[#8fc0ff] to-[#66a6ff] bg-gradient-to-br px-4 py-4 text-white"
>
<i class="i-mdi-wallet-outline pointer-events-none absolute top-9 text-112px text-white opacity-10 -right-7" />
type SummaryMetric = {
label: string
amount?: number | null
cardClass: string
labelClass: string
<view class="relative flex items-center justify-between">
<view class="flex items-center gap-1.5">
<text class="text-13px text-white font-600">
我的钱包
</text>
<view class="h-7 w-7 center" @click.stop="toggleAmountsVisibility">
<i v-if="amountsVisible" class="i-mdi-eye-outline text-14px text-white/80" />
<i v-else class="i-mdi-eye-off-outline text-14px text-white/80" />
</view>
</view>
<view
class="flex items-center rounded-full bg-white px-3 py-1.5"
@click.stop="handleWithdraw"
>
<text class="text-11px text-[#66a6ff] font-600">
去提现
</text>
<image src="@/static/icons/右边.png" class="ml-1 h-12px w-12px" mode="aspectFit" />
</view>
</view>
<view class="relative grid grid-cols-2 mt-4 gap-5">
<view>
<text class="block text-10px text-white/70">
可提现佣金
</text>
<text class="mt-1 block break-all text-22px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.available_commission) }}
</text>
</view>
<view>
<text class="block text-10px text-white/70">
预充值余额
</text>
<text class="mt-1 block break-all text-22px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.main_balance) }}
</text>
</view>
</view>
<view class="relative grid grid-cols-3 mt-4 gap-2 border-t border-white/15 pt-3.5">
<view>
<text class="block text-10px text-white/70">
累计佣金
</text>
<text class="mt-1 block break-all text-16px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.total_commission) }}
</text>
</view>
<view>
<text class="block text-10px text-white/70">
已提现
</text>
<text class="mt-1 block break-all text-16px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.withdrawn_commission) }}
</text>
</view>
<view>
<text class="block text-10px text-white/70">
冻结
</text>
<text class="mt-1 block break-all text-16px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.frozen_commission) }}
</text>
</view>
</view>
</view>
<view v-else class="rounded-20px bg-white p-5">
<view class="rounded-18px from-[#f5fbf5] via-white to-[#f8fafc] bg-gradient-to-br p-4">
<view class="flex items-start justify-between gap-3">
<view class="min-w-0 flex-1">
<view class="inline-flex items-center gap-1 rounded-full bg-[#edf5ff] px-2.5 py-1">
<i class="i-mdi-wallet-outline text-brand text-13px" />
<text class="text-brand text-11px font-600">
可提现佣金
</text>
</view>
<text class="mt-3 block break-all text-34px text-[#1f2937] font-700 leading-none">
{{ formatAmountDisplay(summary?.available_commission) }}
</text>
</view>
<image src="@/static/icons/佣金中心.png" class="h-16 w-16 shrink-0" mode="aspectFit" />
</view>
</view>
<view class="grid grid-cols-2 mt-4 gap-3">
<view
v-for="metric in metrics"
:key="metric.label"
:class="metric.cardClass"
class="rounded-16px px-3 py-3"
>
<text :class="metric.labelClass" class="block text-11px">
{{ metric.label }}
</text>
<text class="mt-2 block break-all text-15px text-[#1f2937] font-700">
{{ formatAmountDisplay(metric.amount) }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { FundSummary } from '@/api/commission';
import { computed, ref } from 'vue';
interface SummaryMetric {
label: string;
amount?: number | null;
cardClass: string;
labelClass: string;
}
const props = defineProps<{
summary?: FundSummary | null
}>()
summary?: FundSummary | null;
variant?: 'default' | 'wallet';
}>();
const emit = defineEmits<{
withdraw: [];
}>();
const amountsVisible = ref(true);
const metrics = computed<SummaryMetric[]>(() => [
{
label: '预充值余额',
amount: props.summary?.main_balance,
cardClass: 'bg-[#f4fbf5] border border-[#e3f2e6]',
labelClass: 'text-[#2f7d32]',
cardClass: 'bg-[#f1f6fc] border border-[#e1ecfa]',
labelClass: 'text-[var(--brand-primary-dark)]',
},
{
label: '累计佣金',
@@ -38,51 +156,29 @@ const metrics = computed<SummaryMetric[]>(() => [
cardClass: 'bg-[#f8fafc] border border-[#eef2f6]',
labelClass: 'text-[#6b7280]',
},
])
]);
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
const yuan = (amount / 100).toFixed(2);
const parts = yuan.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `¥${parts.join('.')}`;
}
function formatAmountDisplay(amount?: number | null) {
return typeof amount === 'number' ? formatAmount(amount) : '--'
return typeof amount === 'number' ? formatAmount(amount) : '--';
}
function formatWalletAmount(amount?: number | null) {
if (!amountsVisible.value) return '***';
return typeof amount === 'number' ? formatAmount(amount).slice(1) : '--';
}
function toggleAmountsVisibility() {
amountsVisible.value = !amountsVisible.value;
}
function handleWithdraw() {
emit('withdraw');
}
</script>
<template>
<view class="bg-white rounded-20px shadow-[0_8px_24px_rgba(15,23,42,0.06)] p-5">
<view class="rounded-18px bg-gradient-to-br from-[#f5fbf5] via-white to-[#f8fafc] p-4">
<view class="flex items-start justify-between gap-3">
<view class="min-w-0 flex-1">
<view class="inline-flex items-center gap-1 rounded-full bg-[#e8f5e9] px-2.5 py-1">
<i class="i-mdi-wallet-outline text-13px text-[#2f7d32]" />
<text class="text-11px font-600 text-[#2f7d32]">可提现佣金</text>
</view>
<text class="text-34px font-700 leading-none text-[#1f2937] block mt-3 break-all">
{{ formatAmountDisplay(summary?.available_commission) }}
</text>
</view>
<image src="@/static/icons/佣金中心.png" class="w-16 h-16 shrink-0" mode="aspectFit" />
</view>
</view>
<view class="grid grid-cols-2 gap-3 mt-4">
<view
v-for="metric in metrics"
:key="metric.label"
:class="metric.cardClass"
class="rounded-16px px-3 py-3"
>
<text :class="metric.labelClass" class="text-11px block">
{{ metric.label }}
</text>
<text class="text-15px font-700 text-[#1f2937] block mt-2 break-all">
{{ formatAmountDisplay(metric.amount) }}
</text>
</view>
</view>
</view>
</template>

View File

@@ -1,107 +1,3 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getShopCascade } from '@/api/shop'
import type { ShopCascadeItem } from '@/api/shop'
interface SelectedShop {
id: number
shop_name: string
}
interface Props {
show: boolean
selectedShop?: SelectedShop | null
title?: string
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'confirm', shop: SelectedShop | null): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: '选择店铺',
selectedShop: null,
})
const emit = defineEmits<Emits>()
const loading = ref(false)
const shopOptions = ref<ShopCascadeItem[]>([])
const breadcrumbs = ref<ShopCascadeItem[]>([])
const selectedShop = ref<SelectedShop | null>(null)
async function loadShopOptions(parentId?: number) {
loading.value = true
try {
const response = await getShopCascade(parentId !== undefined ? { parent_id: parentId } : undefined)
shopOptions.value = response || []
}
catch (error) {
console.error('加载店铺级联数据失败:', error)
shopOptions.value = []
}
finally {
loading.value = false
}
}
async function openPicker() {
selectedShop.value = props.selectedShop ? { ...props.selectedShop } : null
breadcrumbs.value = []
await loadShopOptions()
}
function close() {
emit('update:show', false)
}
function handleCancel() {
emit('cancel')
close()
}
function selectShop(option: ShopCascadeItem) {
selectedShop.value = {
id: option.id,
shop_name: option.shop_name,
}
}
async function enterChildren(option: ShopCascadeItem) {
const nextPath = [...breadcrumbs.value, option]
breadcrumbs.value = nextPath
await loadShopOptions(option.id)
}
async function goToBreadcrumb(index: number) {
if (index < 0) {
breadcrumbs.value = []
await loadShopOptions()
return
}
const nextPath = breadcrumbs.value.slice(0, index + 1)
breadcrumbs.value = nextPath
await loadShopOptions(nextPath[index]?.id)
}
function handleConfirm() {
emit('confirm', selectedShop.value)
close()
}
watch(
() => props.show,
async (show) => {
if (show) {
await openPicker()
}
},
)
</script>
<template>
<view
v-if="show"
@@ -114,13 +10,19 @@ watch(
>
<view class="shop-picker-header">
<view class="shop-picker-action" @click="handleCancel">
<text class="text-sm text-[#666]">取消</text>
<text class="text-sm text-[#666]">
取消
</text>
</view>
<view class="shop-picker-title">
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
<text class="text-base text-[#1e293b] font-600">
{{ title }}
</text>
</view>
<view class="shop-picker-action text-right" @click="handleConfirm">
<text class="text-sm text-brand font-600">确定</text>
<text class="text-brand text-sm font-600">
确定
</text>
</view>
</view>
@@ -140,7 +42,9 @@ watch(
v-for="(item, index) in breadcrumbs"
:key="item.id"
>
<text class="shop-picker-breadcrumb-separator">/</text>
<text class="shop-picker-breadcrumb-separator">
/
</text>
<text
class="shop-picker-breadcrumb-item"
:class="{ 'shop-picker-breadcrumb-item-active': index === breadcrumbs.length - 1 }"
@@ -156,7 +60,7 @@ watch(
<text class="text-12px text-[#666]">
已选店铺
</text>
<text class="text-13px font-600 text-[#212121]">
<text class="text-13px text-[#212121] font-600">
{{ selectedShop.shop_name }}
</text>
</view>
@@ -166,12 +70,16 @@ watch(
scroll-y
>
<view v-if="loading" class="shop-picker-empty">
<i class="i-mdi-loading animate-spin text-18px text-brand" />
<text class="text-13px text-[#64748b]">加载中...</text>
<i class="i-mdi-loading text-brand animate-spin text-18px" />
<text class="text-13px text-[#64748b]">
加载中...
</text>
</view>
<view v-else-if="shopOptions.length === 0" class="shop-picker-empty">
<text class="text-13px text-[#94a3b8]">暂无下级店铺</text>
<text class="text-13px text-[#94a3b8]">
暂无下级店铺
</text>
</view>
<template v-else>
@@ -185,10 +93,12 @@ watch(
class="shop-picker-item-main"
@click="selectShop(option)"
>
<text class="shop-picker-item-text">{{ option.shop_name }}</text>
<text class="shop-picker-item-text">
{{ option.shop_name }}
</text>
<i
v-if="selectedShop?.id === option.id"
class="i-mdi-check text-16px text-brand"
class="i-mdi-check text-brand text-16px"
/>
</view>
<view
@@ -196,7 +106,9 @@ watch(
class="shop-picker-next"
@click="enterChildren(option)"
>
<text class="text-12px text-[#999]">下级</text>
<text class="text-12px text-[#999]">
下级
</text>
<i class="i-mdi-chevron-right text-14px text-[#999]" />
</view>
</view>
@@ -206,6 +118,110 @@ watch(
</view>
</template>
<script setup lang="ts">
import type { ShopCascadeItem } from '@/api/shop';
import { ref, watch } from 'vue';
import { getShopCascade } from '@/api/shop';
interface SelectedShop {
id: number;
shop_name: string;
}
interface Props {
show: boolean;
selectedShop?: SelectedShop | null;
title?: string;
}
interface Emits {
(e: 'update:show', value: boolean): void;
(e: 'confirm', shop: SelectedShop | null): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
title: '选择店铺',
selectedShop: null,
});
const emit = defineEmits<Emits>();
const loading = ref(false);
const shopOptions = ref<ShopCascadeItem[]>([]);
const breadcrumbs = ref<ShopCascadeItem[]>([]);
const selectedShop = ref<SelectedShop | null>(null);
async function loadShopOptions(parentId?: number) {
loading.value = true;
try {
const response = await getShopCascade(parentId !== undefined ? { parent_id: parentId } : undefined);
shopOptions.value = response || [];
}
catch (error) {
console.error('加载店铺级联数据失败:', error);
shopOptions.value = [];
}
finally {
loading.value = false;
}
}
async function openPicker() {
selectedShop.value = props.selectedShop ? { ...props.selectedShop } : null;
breadcrumbs.value = [];
await loadShopOptions();
}
function close() {
emit('update:show', false);
}
function handleCancel() {
emit('cancel');
close();
}
function selectShop(option: ShopCascadeItem) {
selectedShop.value = {
id: option.id,
shop_name: option.shop_name,
};
}
async function enterChildren(option: ShopCascadeItem) {
const nextPath = [...breadcrumbs.value, option];
breadcrumbs.value = nextPath;
await loadShopOptions(option.id);
}
async function goToBreadcrumb(index: number) {
if (index < 0) {
breadcrumbs.value = [];
await loadShopOptions();
return;
}
const nextPath = breadcrumbs.value.slice(0, index + 1);
breadcrumbs.value = nextPath;
await loadShopOptions(nextPath[index]?.id);
}
function handleConfirm() {
emit('confirm', selectedShop.value);
close();
}
watch(
() => props.show,
async (show) => {
if (show) {
await openPicker();
}
},
);
</script>
<style scoped>
.shop-picker-overlay {
position: fixed;
@@ -260,7 +276,7 @@ watch(
}
.shop-picker-breadcrumb-item-active {
color: var(--brand-primary, #1677ff);
color: var(--brand-primary, #66a6ff);
font-weight: 600;
}
@@ -318,7 +334,7 @@ watch(
}
.shop-picker-item-active .shop-picker-item-text {
color: var(--brand-primary, #1677ff);
color: var(--brand-primary, #66a6ff);
font-weight: 600;
}

View File

@@ -1,92 +1,3 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
interface PickerOption {
label: string
value: any
}
interface Props {
show: boolean
options: PickerOption[]
title?: string
autoConfirm?: boolean
selectedValue?: any
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'confirm', option: PickerOption): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: '请选择',
autoConfirm: false,
})
const emit = defineEmits<Emits>()
const selectedIndex = ref(0)
function syncSelectedIndex() {
if (!props.options.length) {
selectedIndex.value = 0
return
}
const matchedIndex = props.options.findIndex(option => option.value === props.selectedValue)
selectedIndex.value = matchedIndex >= 0 ? matchedIndex : 0
}
watch(() => props.show, (show) => {
if (show) {
syncSelectedIndex()
}
}, { immediate: true })
watch(() => props.selectedValue, () => {
if (props.show) {
syncSelectedIndex()
}
})
watch(() => props.options, () => {
if (props.show) {
syncSelectedIndex()
}
}, { deep: true })
// 关闭弹窗
function close() {
emit('update:show', false)
}
// 取消
function handleCancel() {
emit('cancel')
close()
}
// 确认
function handleConfirm() {
if (props.options.length > 0) {
emit('confirm', props.options[selectedIndex.value])
}
close()
}
// 选择选项
function selectOption(index: number) {
selectedIndex.value = index
if (props.autoConfirm && props.options[index]) {
emit('confirm', props.options[index])
close()
}
}
</script>
<template>
<view
v-if="show"
@@ -100,17 +11,23 @@ function selectOption(index: number) {
<!-- 头部 -->
<view class="simple-picker-header">
<view class="simple-picker-cancel" @click="handleCancel">
<text class="text-sm text-[#666]">取消</text>
<text class="text-sm text-[#666]">
取消
</text>
</view>
<view class="simple-picker-title">
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
<text class="text-base text-[#1e293b] font-600">
{{ title }}
</text>
</view>
<view
v-if="!autoConfirm"
class="simple-picker-confirm"
@click="handleConfirm"
>
<text class="text-sm text-brand font-600">确定</text>
<text class="text-brand text-sm font-600">
确定
</text>
</view>
<view
v-else
@@ -130,10 +47,12 @@ function selectOption(index: number) {
:class="{ 'simple-picker-item-active': selectedIndex === index }"
@click="selectOption(index)"
>
<text class="simple-picker-item-text">{{ option.label }}</text>
<text class="simple-picker-item-text">
{{ option.label }}
</text>
<i
v-if="selectedIndex === index"
class="i-mdi-check text-lg text-brand"
class="i-mdi-check text-brand text-lg"
/>
</view>
</scroll-view>
@@ -141,6 +60,95 @@ function selectOption(index: number) {
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
interface PickerOption {
label: string;
value: any;
}
interface Props {
show: boolean;
options: PickerOption[];
title?: string;
autoConfirm?: boolean;
selectedValue?: any;
}
interface Emits {
(e: 'update:show', value: boolean): void;
(e: 'confirm', option: PickerOption): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
title: '请选择',
autoConfirm: false,
});
const emit = defineEmits<Emits>();
const selectedIndex = ref(0);
function syncSelectedIndex() {
if (!props.options.length) {
selectedIndex.value = 0;
return;
}
const matchedIndex = props.options.findIndex(option => option.value === props.selectedValue);
selectedIndex.value = matchedIndex >= 0 ? matchedIndex : 0;
}
watch(() => props.show, (show) => {
if (show) {
syncSelectedIndex();
}
}, { immediate: true });
watch(() => props.selectedValue, () => {
if (props.show) {
syncSelectedIndex();
}
});
watch(() => props.options, () => {
if (props.show) {
syncSelectedIndex();
}
}, { deep: true });
// 关闭弹窗
function close() {
emit('update:show', false);
}
// 取消
function handleCancel() {
emit('cancel');
close();
}
// 确认
function handleConfirm() {
if (props.options.length > 0) {
emit('confirm', props.options[selectedIndex.value]);
}
close();
}
// 选择选项
function selectOption(index: number) {
selectedIndex.value = index;
if (props.autoConfirm && props.options[index]) {
emit('confirm', props.options[index]);
close();
}
}
</script>
<style scoped>
.simple-picker-overlay {
position: fixed;
@@ -220,7 +228,7 @@ function selectOption(index: number) {
}
.simple-picker-item-active .simple-picker-item-text {
color: var(--brand-primary, #1677ff);
color: var(--brand-primary, #66a6ff);
font-weight: 600;
}
</style>

View File

@@ -3,12 +3,12 @@
<view v-if="type === 'home'" class="skeleton-home">
<!-- 顶部用户信息骨架 -->
<view class="px-4 pt-4">
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] px-4 py-4">
<view class="rounded-16px bg-white px-4 py-4">
<view class="flex items-center">
<view class="skeleton-circle w-56px h-56px mr-4" />
<view class="skeleton-circle mr-4 h-56px w-56px" />
<view class="flex-1">
<view class="skeleton-block h-5 w-32 mb-2" />
<view class="skeleton-block h-3 w-16 mb-2" />
<view class="skeleton-block mb-2 h-5 w-32" />
<view class="skeleton-block mb-2 h-3 w-16" />
<view class="skeleton-block h-3 w-24" />
</view>
</view>
@@ -17,19 +17,19 @@
<!-- 数据卡片骨架 -->
<view class="px-4 pt-4">
<view class="skeleton-block h-5 w-24 mb-3" />
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<view class="skeleton-block mb-3 h-5 w-24" />
<view class="rounded-16px bg-white p-4">
<view class="flex justify-between">
<view>
<view class="skeleton-block h-3 w-20 mb-1" />
<view class="skeleton-block mb-1 h-3 w-20" />
<view class="skeleton-block h-5 w-24" />
</view>
<view>
<view class="skeleton-block h-3 w-16 mb-1" />
<view class="skeleton-block mb-1 h-3 w-16" />
<view class="skeleton-block h-5 w-20" />
</view>
<view class="text-right">
<view class="skeleton-block h-3 w-20 mb-1" />
<view class="skeleton-block mb-1 h-3 w-20" />
<view class="skeleton-block h-5 w-24" />
</view>
</view>
@@ -37,13 +37,13 @@
</view>
<!-- 快捷功能骨架 -->
<view class="px-4 pt-6 pb-4">
<view class="skeleton-block h-5 w-24 mb-3" />
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<view class="px-4 pb-4 pt-6">
<view class="skeleton-block mb-3 h-5 w-24" />
<view class="overflow-hidden rounded-16px bg-white">
<view v-for="i in 5" :key="i" class="flex items-center px-4 py-4" :class="{ 'border-t border-[#f1f5f9]': i > 1 }">
<view class="skeleton-circle w-10 h-10 mr-3" />
<view class="skeleton-circle mr-3 h-10 w-10" />
<view class="flex-1">
<view class="skeleton-block h-4 w-20 mb-1" />
<view class="skeleton-block mb-1 h-4 w-20" />
<view class="skeleton-block h-3 w-32" />
</view>
</view>
@@ -53,15 +53,15 @@
<view v-else-if="type === 'list'" class="skeleton-list">
<view class="px-4 pt-4">
<view v-for="i in 3" :key="i" class="bg-white rounded-2xl p-4 mb-3">
<view class="flex items-start justify-between mb-3">
<view v-for="i in 3" :key="i" class="mb-3 rounded-2xl bg-white p-4">
<view class="mb-3 flex items-start justify-between">
<view class="flex-1">
<view class="skeleton-block h-4 w-40 mb-2" />
<view class="skeleton-block mb-2 h-4 w-40" />
<view class="skeleton-block h-3 w-48" />
</view>
<view class="skeleton-block h-5 w-12 rounded-full" />
</view>
<view class="flex items-center justify-between pt-3 border-t border-[#f1f5f9]">
<view class="flex items-center justify-between border-t border-[#f1f5f9] pt-3">
<view class="skeleton-block h-3 w-16" />
<view class="skeleton-block h-4 w-4" />
</view>
@@ -73,8 +73,8 @@
<script setup lang="ts">
defineProps<{
type?: 'home' | 'list'
}>()
type?: 'home' | 'list';
}>();
</script>
<style scoped>

File diff suppressed because it is too large Load Diff

View File

@@ -1,105 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAssetWallet } from '@/api/wallet'
import type { AssetWalletInfo, AssetType } from '@/api/wallet'
import { onLoad } from '@dcloudio/uni-app'
// 资产类型和ID (从URL参数获取)
const assetType = ref<AssetType>('card')
const assetId = ref<number>(0)
// 钱包信息
const walletInfo = ref<AssetWalletInfo | null>(null)
// 加载状态
const loading = ref(false)
// 格式化金额(分转元)
function formatAmount(amount : number) {
return `¥${(amount / 100).toFixed(2)}`
}
// 获取钱包状态文本
function getStatusText(status : number, statusText ?: string) {
if (statusText) return statusText
const map : Record<number, string> = {
1: '正常',
2: '冻结',
3: '已停用',
}
return map[status] || '未知'
}
// 获取钱包状态颜色
function getStatusColor(status : number) {
const map : Record<number, { bg : string, text : string }> = {
1: { bg: '#e8f5e9', text: '#2e7d32' }, // 正常
2: { bg: '#fff3e0', text: '#e65100' }, // 冻结
3: { bg: '#f5f5f5', text: '#999' }, // 已停用
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
// 格式化时间
function formatTime(time : string) {
if (!time) return '-'
const date = new Date(time)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 页面加载时获取参数
onLoad((options) => {
if (options.asset_type) {
assetType.value = options.asset_type as AssetType
}
if (options.asset_id) {
assetId.value = Number(options.asset_id)
}
})
// 页面挂载
onMounted(() => {
if (assetId.value) {
loadWalletInfo()
}
})
// 加载钱包信息
async function loadWalletInfo() {
if (!assetId.value) {
uni.$u.toast('缺少资产ID')
return
}
loading.value = true
try {
const wallet = await getAssetWallet(assetType.value, assetId.value)
walletInfo.value = wallet
}
catch (error) {
console.error('加载资产钱包信息失败:', error)
}
finally {
loading.value = false
}
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<view class="min-h-screen bg-[#fafafa] pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<view v-if="loading" class="center pt-20">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
@@ -107,34 +9,41 @@
<view v-else-if="walletInfo" class="px-4 pt-4">
<!-- 余额卡片 -->
<view
class="bg-gradient-to-r from-[var(--brand-primary)] to-[var(--brand-primary-light)] rounded-16px shadow-brand-colored p-4 mb-3">
<view class="flex items-center justify-between mb-3">
class="mb-3 rounded-16px from-[var(--brand-primary)] to-[var(--brand-primary-light)] bg-gradient-to-r p-4"
>
<view class="mb-3 flex items-center justify-between">
<view>
<text class="text-13px text-white/70 block mb-1">
<text class="mb-1 block text-13px text-white/70">
可用余额
</text>
<text class="text-32px font-700 text-white block">
<text class="block text-32px text-white font-700">
{{ formatAmount(walletInfo.available_balance) }}
</text>
</view>
<view :style="{
<view
:style="{
backgroundColor: getStatusColor(walletInfo.status).bg,
color: getStatusColor(walletInfo.status).text,
}" class="px-3 py-1 rounded-8px text-12px font-600">
}" class="rounded-8px px-3 py-1 text-12px font-600"
>
{{ getStatusText(walletInfo.status, walletInfo.status_text) }}
</view>
</view>
<view class="grid grid-cols-2 gap-3 pt-3 border-t-1px border-white/20">
<view class="grid grid-cols-2 gap-3 border-t-1px border-white/20 pt-3">
<view>
<text class="text-11px text-white/70 block mb-1">总余额</text>
<text class="text-14px font-600 text-white">
<text class="mb-1 block text-11px text-white/70">
总余额
</text>
<text class="text-14px text-white font-600">
{{ formatAmount(walletInfo.balance) }}
</text>
</view>
<view>
<text class="text-11px text-white/70 block mb-1">冻结金额</text>
<text class="text-14px font-600 text-white">
<text class="mb-1 block text-11px text-white/70">
冻结金额
</text>
<text class="text-14px text-white font-600">
{{ formatAmount(walletInfo.frozen_balance) }}
</text>
</view>
@@ -142,52 +51,64 @@
</view>
<!-- 钱包详情 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="flex items-center gap-2 mb-3">
<view class="mb-3 rounded-16px bg-white p-4">
<view class="mb-3 flex items-center gap-2">
<i class="i-mdi-information-outline text-20px text-[var(--brand-primary)]" />
<text class="text-15px font-600 text-[#212121]">
<text class="text-15px text-[#212121] font-600">
钱包详情
</text>
</view>
<view class="space-y-2">
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">钱包ID</text>
<text class="text-13px text-[#666]">
钱包ID
</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.wallet_id }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">资产ID</text>
<text class="text-13px text-[#666]">
资产ID
</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.resource_id }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">资产类型</text>
<text class="text-13px text-[#666]">
资产类型
</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.resource_type === 'iot_card' ? 'IoT卡' : '设备' }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">币种</text>
<text class="text-13px text-[#666]">
币种
</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.currency }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">创建时间</text>
<text class="text-13px text-[#666]">
创建时间
</text>
<text class="text-11px text-[#999]">
{{ formatTime(walletInfo.created_at) }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">更新时间</text>
<text class="text-13px text-[#666]">
更新时间
</text>
<text class="text-11px text-[#999]">
{{ formatTime(walletInfo.updated_at) }}
</text>
@@ -196,11 +117,11 @@
</view>
<!-- 温馨提示 -->
<view class="bg-[#fff3e0] rounded-12px p-3 mb-3">
<view class="mb-3 rounded-12px bg-[#fff3e0] p-3">
<view class="flex items-start gap-2">
<i class="i-mdi-information text-16px text-[#e65100] mt-0.5" />
<i class="i-mdi-information mt-0.5 text-16px text-[#e65100]" />
<view class="flex-1">
<text class="text-12px text-[#666] block">
<text class="block text-12px text-[#666]">
此钱包为资产专属钱包,余额仅可用于该资产相关的费用支付
</text>
</view>
@@ -209,9 +130,9 @@
</view>
<!-- 空状态 -->
<view v-else class="pt-20 center flex-col">
<i class="i-mdi-wallet-outline text-60px text-[#ddd] mb-3" />
<text class="text-14px text-[#999] mb-2">
<view v-else class="center flex-col pt-20">
<i class="i-mdi-wallet-outline mb-3 text-60px text-[#ddd]" />
<text class="mb-2 text-14px text-[#999]">
暂无钱包信息
</text>
<text class="text-12px text-[#ccc]">
@@ -224,6 +145,104 @@
</view>
</template>
<script setup lang="ts">
import type { AssetType, AssetWalletInfo } from '@/api/wallet';
import { onLoad } from '@dcloudio/uni-app';
import { onMounted, ref } from 'vue';
import { getAssetWallet } from '@/api/wallet';
// 资产类型和ID (从URL参数获取)
const assetType = ref<AssetType>('card');
const assetId = ref<number>(0);
// 钱包信息
const walletInfo = ref<AssetWalletInfo | null>(null);
// 加载状态
const loading = ref(false);
// 格式化金额(分转元)
function formatAmount(amount: number) {
return `¥${(amount / 100).toFixed(2)}`;
}
// 获取钱包状态文本
function getStatusText(status: number, statusText?: string) {
if (statusText) return statusText;
const map: Record<number, string> = {
1: '正常',
2: '冻结',
3: '已停用',
};
return map[status] || '未知';
}
// 获取钱包状态颜色
function getStatusColor(status: number) {
const map: Record<number, { bg: string; text: string }> = {
1: { bg: '#e8f5e9', text: '#2e7d32' }, // 正常
2: { bg: '#fff3e0', text: '#e65100' }, // 冻结
3: { bg: '#f5f5f5', text: '#999' }, // 已停用
};
return map[status] || { bg: '#f5f5f5', text: '#999' };
}
// 格式化时间
function formatTime(time: string) {
if (!time) return '-';
const date = new Date(time);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
// 页面加载时获取参数
onLoad((options) => {
if (options.asset_type) {
assetType.value = options.asset_type as AssetType;
}
if (options.asset_id) {
assetId.value = Number(options.asset_id);
}
});
// 页面挂载
onMounted(() => {
if (assetId.value) {
loadWalletInfo();
}
});
// 加载钱包信息
async function loadWalletInfo() {
if (!assetId.value) {
uni.$u.toast('缺少资产ID');
return;
}
loading.value = true;
try {
const wallet = await getAssetWallet(assetType.value, assetId.value);
walletInfo.value = wallet;
}
catch (error) {
console.error('加载资产钱包信息失败:', error);
}
finally {
loading.value = false;
}
}
// 返回上一页
function goBack() {
uni.navigateBack();
}
</script>
<style scoped>
.center {
display: flex;

File diff suppressed because it is too large Load Diff

View File

@@ -1,197 +1,50 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getFundSummary, getCommissionRecords, getCommissionStats } from '@/api/commission'
import type { FundSummary, CommissionRecord, CommissionStats } from '@/api/commission'
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue'
import SimplePicker from '@/components/SimplePicker.vue'
type LoginUserInfo = {
shop_id?: number
username?: string
}
const commissionSummary = ref<FundSummary | null>(null)
const shopId = ref<number>(0)
const commissionRecords = ref<CommissionRecord[]>([])
const commissionStats = ref<CommissionStats | null>(null)
const loadingRecords = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const searchKeyword = ref('')
const sourceFilter = ref<string | undefined>(undefined)
const showSourcePicker = ref(false)
const showSearchTypePicker = ref(false)
const sourceOptions = [
{ label: '全部来源', value: undefined },
{ label: '成本价差', value: 'cost_diff' },
{ label: '一次性佣金', value: 'one_time' },
]
const searchType = ref<'iccid' | 'virtual_no' | 'order_no'>('iccid')
const searchTypeOptions = [
{ label: 'ICCID', value: 'iccid' },
{ label: '虚拟号', value: 'virtual_no' },
{ label: '订单号', value: 'order_no' }
]
const selectedSourceName = computed(() => {
if (sourceFilter.value === undefined) return '来源'
const option = sourceOptions.find(o => o.value === sourceFilter.value)
return option?.label || '来源'
})
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
}
function getSourceText(source: string) {
const map: Record<string, string> = {
cost_diff: '成本价差',
one_time: '一次性佣金',
tier_bonus: '梯度奖励',
}
return map[source] || source
}
function getStatusText(status: number) {
return status === 1 ? '已入账' : '已失效'
}
function formatTime(time: string) {
if (!time) return '-'
const date = new Date(time)
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as LoginUserInfo | undefined
}
onMounted(() => {
const userInfo = getLoginUserInfo()
if (userInfo?.shop_id) {
shopId.value = userInfo.shop_id
}
loadCommissionSummary()
loadCommissionStats()
loadCommissionRecords()
})
async function loadCommissionSummary() {
try {
const userInfo = getLoginUserInfo()
commissionSummary.value = await getFundSummary(userInfo?.username ? { username: userInfo.username } : undefined)
} catch (error) {
console.error('加载佣金概览失败:', error)
}
}
async function loadCommissionStats() {
if (!shopId.value) return
try {
commissionStats.value = await getCommissionStats(shopId.value)
} catch (error) {
console.error('加载佣金统计失败:', error)
}
}
async function loadCommissionRecords(isRefresh = false) {
if (!shopId.value) return
if (isRefresh) {
page.value = 1
commissionRecords.value = []
}
loadingRecords.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
if (sourceFilter.value) {
params.commission_source = sourceFilter.value
}
if (searchKeyword.value) {
const keyword = searchKeyword.value.trim()
if (searchType.value === 'iccid') params.iccid = keyword
else if (searchType.value === 'virtual_no') params.virtual_no = keyword
else params.order_no = keyword
}
const response = await getCommissionRecords(shopId.value, params)
if (page.value === 1) {
commissionRecords.value = response.items || []
} else {
commissionRecords.value.push(...(response.items || []))
}
total.value = response.total || 0
} catch (error) {
console.error('加载佣金明细失败:', error)
} finally {
loadingRecords.value = false
}
}
function handleSearch() {
loadCommissionRecords(true)
}
function onSourceConfirm(option: { label: string, value: any }) {
sourceFilter.value = option.value
loadCommissionRecords(true)
}
function onSearchTypeConfirm(option: { label: string, value: any }) {
searchType.value = option.value
}
function loadMore() {
if (commissionRecords.value.length < total.value) {
page.value++
loadCommissionRecords()
}
}
</script>
<template>
<view class="bg-page min-h-screen">
<!-- 顶部搜索区域 -->
<view class="bg-white px-4 pt-4 pb-3">
<view class="bg-white px-4 pb-3 pt-4">
<!-- 搜索框 -->
<view class="relative flex items-center">
<input
v-model="searchKeyword"
class="flex-1 h-10 bg-[#f1f5f9] rounded-full pl-4 pr-12 text-sm text-[#1e293b] placeholder-[#94a3b8]"
class="h-10 flex-1 rounded-full bg-[#f1f5f9] pl-4 pr-12 text-sm text-[#1e293b] placeholder-[#94a3b8]"
:placeholder="searchType === 'iccid' ? '搜索ICCID' : searchType === 'virtual_no' ? '搜索虚拟号' : '搜索订单号'"
@confirm="handleSearch"
/>
>
<view class="absolute right-1 flex items-center">
<view v-if="searchKeyword" class="w-8 h-8 flex items-center justify-center text-[#94a3b8]"
@click="searchKeyword = ''">
<view
v-if="searchKeyword" class="h-8 w-8 flex items-center justify-center text-[#94a3b8]"
@click="searchKeyword = ''"
>
<i class="i-mdi-close-circle text-lg" />
</view>
<view class="w-8 h-8 flex items-center justify-center text-brand" @click="handleSearch">
<view class="text-brand h-8 w-8 flex items-center justify-center" @click="handleSearch">
<i class="i-mdi-magnify text-xl" />
</view>
</view>
</view>
<!-- 筛选器 -->
<view class="flex items-center gap-2 mt-3">
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full" @click="showSearchTypePicker = true">
<text class="text-12px text-[#64748b]">{{ searchType === 'iccid' ? 'ICCID' : searchType === 'virtual_no' ? '虚拟号' : '订单号' }}</text>
<view class="mt-3 flex items-center gap-2">
<view class="flex items-center gap-1 rounded-full bg-[#f8fafc] px-3 py-1.5" @click="showSearchTypePicker = true">
<text class="text-12px text-[#64748b]">
{{ searchType === 'iccid' ? 'ICCID' : searchType === 'virtual_no' ? '虚拟号' : '订单号' }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full" @click="showSourcePicker = true">
<text class="text-12px" :class="sourceFilter !== undefined ? 'text-brand font-medium' : 'text-[#64748b]'">{{ selectedSourceName }}</text>
<view class="flex items-center gap-1 rounded-full bg-[#f8fafc] px-3 py-1.5" @click="showSourcePicker = true">
<text class="text-12px" :class="sourceFilter !== undefined ? 'text-brand font-medium' : 'text-[#64748b]'">
{{ selectedSourceName }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view v-if="sourceFilter !== undefined" class="flex items-center gap-1 px-2 py-1.5 rounded-full bg-[#ff4d4f]/10 text-[#ff4d4f]"
@click="sourceFilter = undefined; loadCommissionRecords(true)">
<view
v-if="sourceFilter !== undefined" class="flex items-center gap-1 rounded-full bg-[#ff4d4f]/10 px-2 py-1.5 text-[#ff4d4f]"
@click="sourceFilter = undefined; loadCommissionRecords(true)"
>
<i class="i-mdi-close text-12px" />
<text class="text-12px font-medium">清除</text>
<text class="text-12px font-medium">
清除
</text>
</view>
</view>
</view>
@@ -202,62 +55,88 @@ function loadMore() {
<AgentFundSummaryCard class="mt-4" :summary="commissionSummary" />
<!-- 佣金统计 -->
<view class="bg-white rounded-16px p-4 mt-3 shadow-[0_2px_8px_rgba(0,0,0,0.04)]">
<text class="text-14px font-600 text-[#212121]">佣金统计</text>
<view class="mt-3 rounded-16px bg-white p-4">
<text class="text-14px text-[#212121] font-600">
佣金统计
</text>
<view class="mt-3 space-y-3">
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<view class="w-2 h-2 rounded-full bg-[#2196f3]" />
<text class="text-13px text-[#666]">成本价差</text>
<view class="h-2 w-2 rounded-full bg-[#2196f3]" />
<text class="text-13px text-[#666]">
成本价差
</text>
</view>
<view class="flex items-center gap-2">
<span class="text-11px text-[#999]">{{ commissionStats ? (commissionStats.cost_diff_percent / 10).toFixed(1) + '%' : '--' }}</span>
<text class="text-14px font-600 text-[#212121]">{{ commissionStats ? formatAmount(commissionStats.cost_diff_amount) : '--' }}</text>
<span class="text-11px text-[#999]">{{ commissionStats ? `${(commissionStats.cost_diff_percent / 10).toFixed(1)}%` : '--' }}</span>
<text class="text-14px text-[#212121] font-600">
{{ commissionStats ? formatAmount(commissionStats.cost_diff_amount) : '--' }}
</text>
</view>
</view>
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<view class="w-2 h-2 rounded-full bg-[#ff9800]" />
<text class="text-13px text-[#666]">一次性佣金</text>
<view class="h-2 w-2 rounded-full bg-[#ff9800]" />
<text class="text-13px text-[#666]">
一次性佣金
</text>
</view>
<view class="flex items-center gap-2">
<span class="text-11px text-[#999]">{{ commissionStats ? (commissionStats.one_time_percent / 10).toFixed(1) + '%' : '--' }}</span>
<text class="text-14px font-600 text-[#212121]">{{ commissionStats ? formatAmount(commissionStats.one_time_amount) : '--' }}</text>
<span class="text-11px text-[#999]">{{ commissionStats ? `${(commissionStats.one_time_percent / 10).toFixed(1)}%` : '--' }}</span>
<text class="text-14px text-[#212121] font-600">
{{ commissionStats ? formatAmount(commissionStats.one_time_amount) : '--' }}
</text>
</view>
</view>
<view class="flex items-center justify-between pt-3 border-t border-[#f0f0f0]">
<text class="text-13px font-600 text-[#212121]">总计</text>
<text class="text-16px font-700 text-brand">{{ commissionStats ? formatAmount(commissionStats.total_amount) : '--' }}</text>
<view class="flex items-center justify-between border-t border-[#f0f0f0] pt-3">
<text class="text-13px text-[#212121] font-600">
总计
</text>
<text class="text-brand text-16px font-700">
{{ commissionStats ? formatAmount(commissionStats.total_amount) : '--' }}
</text>
</view>
</view>
</view>
<!-- 佣金明细 -->
<view class="mt-4">
<view class="flex items-center justify-between mb-3">
<text class="text-14px font-600 text-[#212121]">佣金明细</text>
<text class="text-12px text-[#999]"> {{ total }} </text>
<view class="mb-3 flex items-center justify-between">
<text class="text-14px text-[#212121] font-600">
佣金明细
</text>
<text class="text-12px text-[#999]">
{{ total }}
</text>
</view>
<view v-if="loadingRecords && page === 1" class="pt-20 flex justify-center">
<text class="text-14px text-[#64748b]">加载中...</text>
<view v-if="loadingRecords && page === 1" class="flex justify-center pt-20">
<text class="text-14px text-[#64748b]">
加载中...
</text>
</view>
<view v-else-if="commissionRecords.length > 0" class="space-y-2">
<view
v-for="record in commissionRecords"
:key="record.id"
class="bg-white rounded-12px p-4"
class="rounded-12px bg-white p-4"
>
<view class="flex items-center justify-between mb-2">
<view class="mb-2 flex items-center justify-between">
<view class="flex items-center gap-2">
<text class="text-14px font-500 text-[#212121]">{{ getSourceText(record.commission_source) }}</text>
<view :class="record.status === 1 ? 'bg-[#e8f5e9] text-[#2e7d32]' : 'bg-[#f5f5f5] text-[#999]'"
class="px-2 py-0.5 rounded text-11px">
<text class="text-14px text-[#212121] font-500">
{{ getSourceText(record.commission_source) }}
</text>
<view
:class="record.status === 1 ? 'bg-[#e8f5e9] text-[#2e7d32]' : 'bg-[#f5f5f5] text-[#999]'"
class="rounded px-2 py-0.5 text-11px"
>
{{ getStatusText(record.status) }}
</view>
</view>
<text class="text-16px font-600 text-[#52c41a]">+{{ formatAmount(record.amount) }}</text>
<text class="text-16px text-[#52c41a] font-600">
+{{ formatAmount(record.amount) }}
</text>
</view>
<view class="flex items-center justify-between text-12px text-[#999]">
<text>{{ formatTime(record.created_at) }}</text>
@@ -265,17 +144,25 @@ function loadMore() {
</view>
<view v-if="commissionRecords.length < total" class="py-4 text-center">
<text v-if="loadingRecords" class="text-12px text-[#64748b]">加载中...</text>
<text v-else class="text-12px text-brand" @click="loadMore">加载更多</text>
<text v-if="loadingRecords" class="text-12px text-[#64748b]">
加载中...
</text>
<text v-else class="text-brand text-12px" @click="loadMore">
加载更多
</text>
</view>
<view v-else class="py-4 text-center">
<text class="text-12px text-[#cbd5e1]">没有更多了</text>
<text class="text-12px text-[#cbd5e1]">
没有更多了
</text>
</view>
</view>
<view v-else class="pt-16 flex flex-col items-center">
<i class="i-mdi-file-document-outline text-5xl text-[#e0e0e0] mb-3" />
<text class="text-14px text-[#999]">{{ searchKeyword ? '未找到相关结果' : '暂无佣金记录' }}</text>
<view v-else class="flex flex-col items-center pt-16">
<i class="i-mdi-file-document-outline mb-3 text-5xl text-[#e0e0e0]" />
<text class="text-14px text-[#999]">
{{ searchKeyword ? '未找到相关结果' : '暂无佣金记录' }}
</text>
</view>
</view>
</view>
@@ -296,6 +183,167 @@ function loadMore() {
</view>
</template>
<script setup lang="ts">
import type { CommissionRecord, CommissionStats, FundSummary } from '@/api/commission';
import { computed, onMounted, ref } from 'vue';
import { getCommissionRecords, getCommissionStats, getFundSummary } from '@/api/commission';
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue';
import SimplePicker from '@/components/SimplePicker.vue';
interface LoginUserInfo {
shop_id?: number;
username?: string;
}
const commissionSummary = ref<FundSummary | null>(null);
const shopId = ref<number>(0);
const commissionRecords = ref<CommissionRecord[]>([]);
const commissionStats = ref<CommissionStats | null>(null);
const loadingRecords = ref(false);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const searchKeyword = ref('');
const sourceFilter = ref<string | undefined>(undefined);
const showSourcePicker = ref(false);
const showSearchTypePicker = ref(false);
const sourceOptions = [
{ label: '全部来源', value: undefined },
{ label: '成本价差', value: 'cost_diff' },
{ label: '一次性佣金', value: 'one_time' },
];
const searchType = ref<'iccid' | 'virtual_no' | 'order_no'>('iccid');
const searchTypeOptions = [
{ label: 'ICCID', value: 'iccid' },
{ label: '虚拟号', value: 'virtual_no' },
{ label: '订单号', value: 'order_no' },
];
const selectedSourceName = computed(() => {
if (sourceFilter.value === undefined) return '来源';
const option = sourceOptions.find(o => o.value === sourceFilter.value);
return option?.label || '来源';
});
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2);
const parts = yuan.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `¥${parts.join('.')}`;
}
function getSourceText(source: string) {
const map: Record<string, string> = {
cost_diff: '成本价差',
one_time: '一次性佣金',
tier_bonus: '梯度奖励',
};
return map[source] || source;
}
function getStatusText(status: number) {
return status === 1 ? '已入账' : '已失效';
}
function formatTime(time: string) {
if (!time) return '-';
const date = new Date(time);
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as LoginUserInfo | undefined;
}
onMounted(() => {
const userInfo = getLoginUserInfo();
if (userInfo?.shop_id) {
shopId.value = userInfo.shop_id;
}
loadCommissionSummary();
loadCommissionStats();
loadCommissionRecords();
});
async function loadCommissionSummary() {
try {
const userInfo = getLoginUserInfo();
commissionSummary.value = await getFundSummary(userInfo?.username ? { username: userInfo.username } : undefined);
}
catch (error) {
console.error('加载佣金概览失败:', error);
}
}
async function loadCommissionStats() {
if (!shopId.value) return;
try {
commissionStats.value = await getCommissionStats(shopId.value);
}
catch (error) {
console.error('加载佣金统计失败:', error);
}
}
async function loadCommissionRecords(isRefresh = false) {
if (!shopId.value) return;
if (isRefresh) {
page.value = 1;
commissionRecords.value = [];
}
loadingRecords.value = true;
try {
const params: any = { page: page.value, page_size: pageSize };
if (sourceFilter.value) {
params.commission_source = sourceFilter.value;
}
if (searchKeyword.value) {
const keyword = searchKeyword.value.trim();
if (searchType.value === 'iccid') params.iccid = keyword;
else if (searchType.value === 'virtual_no') params.virtual_no = keyword;
else params.order_no = keyword;
}
const response = await getCommissionRecords(shopId.value, params);
if (page.value === 1) {
commissionRecords.value = response.items || [];
}
else {
commissionRecords.value.push(...(response.items || []));
}
total.value = response.total || 0;
}
catch (error) {
console.error('加载佣金明细失败:', error);
}
finally {
loadingRecords.value = false;
}
}
function handleSearch() {
loadCommissionRecords(true);
}
function onSourceConfirm(option: { label: string; value: any }) {
sourceFilter.value = option.value;
loadCommissionRecords(true);
}
function onSearchTypeConfirm(option: { label: string; value: any }) {
searchType.value = option.value;
}
function loadMore() {
if (commissionRecords.value.length < total.value) {
page.value++;
loadCommissionRecords();
}
}
</script>
<style scoped>
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;

View File

@@ -1,375 +1,162 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import { getUserInfo, logout as logoutApi } from '@/api/auth'
import { clearToken } from '@/utils/auth'
import type { UserInfo } from '@/api/auth'
import { getFundSummary, getCommissionDailyStats } from '@/api/commission'
import type { FundSummary, DailyCommissionStats } from '@/api/commission'
import { getNotificationUnreadCount, getNotifications, markNotificationRead } from '@/api/notifications'
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue'
const NOTIFICATION_REMINDER_PENDING_KEY = 'notification_reminder_pending'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
// 佣金概览
const commissionSummary = ref<FundSummary | null>(null)
// 每日佣金统计 (最近7天)
const dailyStats = ref<DailyCommissionStats[]>([])
// 加载状态
const loading = ref(true)
const loadingCommission = ref(false)
// 店铺ID
const shopId = ref<number>(0)
// 通知未读数
const notificationUnreadCount = ref<number | null>(null)
// 是否为代理账号
const isAgent = computed(() => userInfo.value?.user_type === 3)
const todayStat = computed(() => dailyStats.value[0] || null)
const notificationUnreadDisplayCount = computed(() => {
if (!notificationUnreadCount.value) return ''
return notificationUnreadCount.value > 99 ? '99+' : String(notificationUnreadCount.value)
})
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as Partial<UserInfo> | undefined
}
// 格式化金额(分转元,带千分号)
function formatAmount(amount : number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
}
// 格式化日期 (MM-DD)
// 根据用户类型动态生成菜单
function formatDate(dateStr : string) {
const date = new Date(dateStr)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${month}-${day}`
}
// 加载佣金数据
async function loadCommissionData(username?: string) {
if (!isAgent.value) return
loadingCommission.value = true
try {
// 1. 加载佣金概览 (新接口 /shops/fund-summary)
const summary = await getFundSummary(username ? { username } : undefined)
commissionSummary.value = summary
if (!shopId.value) {
dailyStats.value = []
return
}
const todayStats = await getCommissionDailyStats(shopId.value, {
days: 1,
})
dailyStats.value = todayStats || []
return
// 2. 加载最近30天佣金统计
const stats = await getCommissionDailyStats(shopId.value, { days: 30 })
dailyStats.value = stats || []
}
catch (error) {
console.error('加载佣金数据失败:', error)
}
finally {
loadingCommission.value = false
}
}
// 加载首页数据
async function loadHomeData() {
loading.value = true
try {
// 加载用户信息
const user = await getUserInfo()
userInfo.value = user
// 设置店铺ID
const loginUserInfo = getLoginUserInfo()
shopId.value = user.shop_id || loginUserInfo?.shop_id || 0
const fundSummaryUsername = loginUserInfo?.username || user.username
// 如果是代理账号,加载佣金数据
if (user.user_type === 3) {
await loadCommissionData(fundSummaryUsername)
}
}
catch (error : any) {
console.error('加载首页数据失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
const errorWithStatus = error as { statusCode?: number } | undefined
if (error instanceof Error && error.message && !errorWithStatus?.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
async function loadNotificationUnreadCount() {
try {
const response = await getNotificationUnreadCount()
notificationUnreadCount.value = Math.max(response.count || 0, 0)
}
catch (error) {
console.error('加载通知未读数失败:', error)
}
}
function decreaseNotificationUnreadCount() {
if (notificationUnreadCount.value && notificationUnreadCount.value > 0) {
notificationUnreadCount.value -= 1
}
}
async function showUnreadNotificationReminder() {
if (uni.getStorageSync(NOTIFICATION_REMINDER_PENDING_KEY) !== true) return
// 一次登录只消费一次提醒标记,避免首页重复展示同一条提醒。
uni.removeStorageSync(NOTIFICATION_REMINDER_PENDING_KEY)
try {
const response = await getNotifications({
is_read: false,
page: 1,
page_size: 1,
})
const notification = response.items?.[0]
if (!notification) return
uni.showModal({
title: notification.title || '通知提醒',
content: notification.body || '你有一条新的未读通知',
showCancel: false,
confirmText: '知道了',
})
// 弹窗已经把通知内容呈现给用户,立即同步已读状态,不要求额外点击“已读”。
decreaseNotificationUnreadCount()
void markNotificationRead(notification.id).catch((error) => {
console.error(`标记通知 ${notification.id} 已读失败:`, error)
})
}
catch (error) {
console.error('加载未读通知提醒失败:', error)
}
}
async function loadNotificationData() {
await Promise.all([
loadNotificationUnreadCount(),
showUnreadNotificationReminder(),
])
}
// 跳转页面
function navigateTo(path : string) {
uni.navigateTo({ url: path })
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi()
}
catch (error) {
console.error('登出接口调用失败:', error)
}
finally {
// 清除本地登录状态
clearToken()
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('user_info')
uni.removeStorageSync(NOTIFICATION_REMINDER_PENDING_KEY)
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
})
}
}
},
})
}
onMounted(async () => {
await loadHomeData()
if (userInfo.value) {
await nextTick()
await loadNotificationData()
}
})
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<view class="bg-page min-h-screen pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<text class="text-14px text-[#999]">加载中...</text>
<view v-if="loading" class="center pt-20">
<text class="text-14px text-[#999]">
加载中...
</text>
</view>
<!-- 实际内容 -->
<view v-else>
<!-- 顶部用户信息卡片 -->
<view class="px-4 pt-4">
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] px-4 py-4">
<view class="flex items-center">
<!-- 左侧头像 -->
<view class="w-56px h-56px rounded-full bg-[#f5f5f5] center mr-4"
@click="navigateTo('/pages/agent-system/user-info/index')">
<text class="text-14px text-[#666] font-700">
{{ userInfo?.user_type === 3 ? '代理' : '企业' }}
</text>
</view>
<!-- 顶部用户信息 -->
<view class="flex items-center from-[#dfeafb] via-[#f1f6fc] to-[#f4f6fa] bg-gradient-to-b px-4 pb-4 pt-4">
<view
class="mr-4 h-56px w-56px center rounded-full bg-white"
@click="navigateTo('/pages/agent-system/user-info/index')"
>
<text class="text-14px text-[#666] font-700">
{{ userInitial }}
</text>
</view>
<!-- 右侧用户信息 -->
<view class="flex-1">
<text class="text-18px font-700 text-[#212121] block mb-1">
{{ userInfo?.username || '欢迎回来' }}
</text>
<text class="text-13px text-[#999]">
{{ userInfo?.phone || '-' }}
</text>
</view>
<view class="flex-1">
<text class="mb-1 block text-18px text-[#212121] font-700">
{{ userInfo?.username || '欢迎回来' }}
</text>
<text class="text-13px text-[#999]">
{{ userInfo?.phone || '-' }}
</text>
</view>
<!-- 通知入口 -->
<view class="relative w-44px h-44px rounded-14px bg-[#fff7ed] flex items-center justify-center ml-3" @click="navigateTo('/pages/agent-system/notifications/index')">
<image src="@/static/icons/通知.png" class="w-26px h-26px" mode="aspectFit" />
<view v-if="notificationUnreadDisplayCount" class="absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full bg-[#ff4d4f] flex items-center justify-center">
<text class="text-10px text-white leading-none">{{ notificationUnreadDisplayCount }}</text>
</view>
<!-- 通知入口 -->
<view class="relative ml-3 h-44px w-44px flex items-center justify-center" @click="navigateTo('/pages/agent-system/notifications/index')">
<image src="@/static/icons/通知.png" class="h-22px w-22px" mode="aspectFit" />
<view v-if="notificationUnreadDisplayCount" class="absolute h-5 min-w-5 flex items-center justify-center rounded-full bg-[#ff4d4f] px-1 -right-1 -top-1">
<text class="text-10px text-white leading-none">
{{ notificationUnreadDisplayCount }}
</text>
</view>
</view>
</view>
</view>
<!-- 代理端: 佣金展示 -->
<view v-if="isAgent" class="px-4 pt-4">
<AgentFundSummaryCard
class="mb-3"
:summary="commissionSummary"
variant="wallet"
@click="navigateTo('/pages/agent-system/commission-center/index')"
@withdraw="navigateTo('/pages/agent-system/withdraw/index')"
/>
<!-- 最近一天佣金 -->
<view>
<view class="flex items-center justify-between mb-3">
<text class="text-16px font-700 text-[#212121]">最近一天佣金</text>
<text class="text-13px text-[#999]" @click="navigateTo('/pages/agent-system/commission-center/index')">明细</text>
<view class="rounded-16px bg-white px-4 py-4">
<view class="border-b border-[#f1f3f5] pb-3">
<text class="text-14px text-[#212121] font-700">
最近一天佣金
</text>
</view>
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<view class="grid grid-cols-3 gap-3">
<view class="text-center">
<text class="text-12px text-[#999] block mb-1">日期</text>
<text class="text-14px font-600 text-[#212121]">
{{ todayStat ? formatDate(todayStat.date) : '--' }}
</text>
</view>
<view class="text-center">
<text class="text-12px text-[#999] block mb-1">佣金金额</text>
<text class="text-14px font-600 text-[#212121]">
<view class="grid grid-cols-3 pt-3">
<view class="min-h-14 flex flex-col items-center text-center">
<text class="block text-11px text-[#8f98a6] leading-none">
佣金金额
</text>
<view class="mt-2 h-7 center">
<text class="text-brand block text-20px font-700">
{{ formatAmount(todayStat?.total_amount || 0) }}
</text>
</view>
<view class="text-center">
<text class="text-12px text-[#999] block mb-1">订单笔数</text>
<text class="text-14px font-600 text-[#212121]">
</view>
<view class="min-h-14 flex flex-col items-center border-l border-[#edf0f3] text-center">
<text class="block text-11px text-[#8f98a6] leading-none">
订单笔数
</text>
<view class="mt-2 h-7 center">
<text class="block text-20px text-[#212121] font-600">
{{ todayStat?.total_count || 0 }}
</text>
</view>
</view>
<view v-if="!todayStat" class="mt-3 pt-3 border-t border-[#f5f5f5] text-center">
<text class="text-12px text-[#999]">最近一天暂无佣金数据</text>
<view
class="min-h-14 flex flex-col items-center border-l border-[#edf0f3] text-center"
@click="navigateTo('/pages/agent-system/commission-center/index')"
>
<text class="block text-11px text-[#8f98a6] leading-none">
查看明细
</text>
<view class="mt-2 h-7 center">
<image src="@/static/icons/右边.png" class="h-12px w-12px" mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
<!-- 快捷功能区 -->
<view class="px-4 pt-4">
<text class="text-16px font-700 text-[#212121] block mb-3">快捷功能</text>
<view class="px-4 pb-6 pt-4">
<view class="overflow-hidden rounded-16px bg-white">
<text class="block px-4 pt-4 text-16px text-[#212121] font-700">
常用功能
</text>
<view class="grid grid-cols-3 gap-3 mb-3">
<!-- 资产列表 -->
<view class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/assets/index')">
<image src="@/static/icons/资产列表.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">资产列表</text>
</view>
<!-- 企业端: 授权卡列表 -->
<view v-if="userInfo?.user_type === 4" class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/enterprise-cards/index')">
<image src="@/static/icons/授权卡列表.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">授权卡</text>
</view>
<!-- 企业端: 授权设备列表 -->
<view v-if="userInfo?.user_type === 4" class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/enterprise-devices/index')">
<image src="@/static/icons/授权设备列表.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">授权设备</text>
</view>
<!-- 代理端: 佣金中心 -->
<view v-if="userInfo?.user_type === 3" class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/commission-center/index')">
<image src="@/static/icons/佣金中心.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">佣金中心</text>
</view>
<!-- 代理端: 提现管理 -->
<view v-if="userInfo?.user_type === 3" class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/withdraw/index')">
<image src="@/static/icons/提现管理.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">提现管理</text>
</view>
</view>
</view>
<!-- 设置区 -->
<view class="px-4 pt-4 pb-6">
<text class="text-16px font-700 text-[#212121] block mb-3">设置</text>
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<!-- 修改密码 -->
<view class="flex items-center px-4 py-4 active:bg-[#f8fafc] transition-all duration-200" @click="navigateTo('/pages/agent-system/change-password/index')">
<view class="w-40px h-40px rounded-12px bg-[#f0f5ff] flex items-center justify-center mr-3">
<i class="i-mdi-lock-reset text-20px text-brand" />
<view class="grid grid-cols-4">
<!-- 资产列表 -->
<view class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/assets/index')">
<image src="@/static/icons/资产列表.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
资产列表
</text>
</view>
<view class="flex-1">
<text class="text-15px text-[#212121] font-500 block">修改密码</text>
<text class="text-12px text-[#999] block mt-0.5">更新登录密码</text>
</view>
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
<!-- 退出登录 -->
<view class="flex items-center px-4 py-4 border-t border-[#f5f5f5] active:bg-[#f8fafc] transition-all duration-200" @click="logout">
<view class="w-40px h-40px rounded-12px bg-[#fff2f0] flex items-center justify-center mr-3">
<i class="i-mdi-logout text-20px text-[#ff4d4f]" />
<!-- 企业端: 授权卡列表 -->
<view v-if="userInfo?.user_type === 4" class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/enterprise-cards/index')">
<image src="@/static/icons/授权卡列表.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
授权卡
</text>
</view>
<view class="flex-1">
<text class="text-15px text-[#212121] font-500 block">退出登录</text>
<text class="text-12px text-[#999] block mt-0.5">退出当前账号</text>
<!-- 企业端: 授权设备列表 -->
<view v-if="userInfo?.user_type === 4" class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/enterprise-devices/index')">
<image src="@/static/icons/授权设备列表.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
授权设备
</text>
</view>
<!-- 代理端: 佣金中心 -->
<view v-if="userInfo?.user_type === 3" class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/commission-center/index')">
<image src="@/static/icons/佣金中心.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
佣金中心
</text>
</view>
<!-- 代理端: 提现管理 -->
<view v-if="userInfo?.user_type === 3" class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/withdraw/index')">
<image src="@/static/icons/提现管理.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
提现管理
</text>
</view>
<!-- 修改密码 -->
<view class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/change-password/index')">
<image src="@/static/icons/修改密码.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
修改密码
</text>
</view>
<!-- 退出登录 -->
<view class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="logout">
<image src="@/static/icons/退出登录.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
退出登录
</text>
</view>
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
</view>
</view>
@@ -377,6 +164,222 @@
</view>
</template>
<script setup lang="ts">
import type { UserInfo } from '@/api/auth';
import type { DailyCommissionStats, FundSummary } from '@/api/commission';
import { computed, nextTick, onMounted, ref } from 'vue';
import { getUserInfo, logout as logoutApi } from '@/api/auth';
import { getCommissionDailyStats, getFundSummary } from '@/api/commission';
import { getNotifications, getNotificationUnreadCount, markNotificationRead } from '@/api/notifications';
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue';
import { clearToken } from '@/utils/auth';
const NOTIFICATION_REMINDER_PENDING_KEY = 'notification_reminder_pending';
// 用户信息
const userInfo = ref<UserInfo | null>(null);
// 佣金概览
const commissionSummary = ref<FundSummary | null>(null);
// 每日佣金统计 (最近7天)
const dailyStats = ref<DailyCommissionStats[]>([]);
// 加载状态
const loading = ref(true);
const loadingCommission = ref(false);
// 店铺ID
const shopId = ref<number>(0);
// 通知未读数
const notificationUnreadCount = ref<number | null>(null);
// 是否为代理账号
const isAgent = computed(() => userInfo.value?.user_type === 3);
const userInitial = computed(() => Array.from(userInfo.value?.username?.trim() || '')[0] || '?');
const todayStat = computed(() => dailyStats.value[0] || null);
const notificationUnreadDisplayCount = computed(() => {
if (!notificationUnreadCount.value) return '';
return notificationUnreadCount.value > 99 ? '99+' : String(notificationUnreadCount.value);
});
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as Partial<UserInfo> | undefined;
}
// 格式化金额(分转元,带千分号)
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2);
const parts = yuan.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
// 加载佣金数据
async function loadCommissionData(username?: string) {
if (!isAgent.value) return;
loadingCommission.value = true;
try {
// 1. 加载佣金概览 (新接口 /shops/fund-summary)
const summary = await getFundSummary(username ? { username } : undefined);
commissionSummary.value = summary;
if (!shopId.value) {
dailyStats.value = [];
return;
}
const todayStats = await getCommissionDailyStats(shopId.value, {
days: 1,
});
dailyStats.value = todayStats || [];
return;
// 2. 加载最近30天佣金统计
const stats = await getCommissionDailyStats(shopId.value, { days: 30 });
dailyStats.value = stats || [];
}
catch (error) {
console.error('加载佣金数据失败:', error);
}
finally {
loadingCommission.value = false;
}
}
// 加载首页数据
async function loadHomeData() {
loading.value = true;
try {
// 加载用户信息
const user = await getUserInfo();
userInfo.value = user;
// 设置店铺ID
const loginUserInfo = getLoginUserInfo();
shopId.value = user.shop_id || loginUserInfo?.shop_id || 0;
const fundSummaryUsername = loginUserInfo?.username || user.username;
// 如果是代理账号,加载佣金数据
if (user.user_type === 3) {
await loadCommissionData(fundSummaryUsername);
}
}
catch (error: any) {
console.error('加载首页数据失败:', error);
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
const errorWithStatus = error as { statusCode?: number } | undefined;
if (error instanceof Error && error.message && !errorWithStatus?.statusCode) {
uni.$u.toast(error.message);
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false;
}
}
async function loadNotificationUnreadCount() {
try {
const response = await getNotificationUnreadCount();
notificationUnreadCount.value = Math.max(response.count || 0, 0);
}
catch (error) {
console.error('加载通知未读数失败:', error);
}
}
function decreaseNotificationUnreadCount() {
if (notificationUnreadCount.value && notificationUnreadCount.value > 0) {
notificationUnreadCount.value -= 1;
}
}
async function showUnreadNotificationReminder() {
if (uni.getStorageSync(NOTIFICATION_REMINDER_PENDING_KEY) !== true) return;
// 一次登录只消费一次提醒标记,避免首页重复展示同一条提醒。
uni.removeStorageSync(NOTIFICATION_REMINDER_PENDING_KEY);
try {
const response = await getNotifications({
is_read: false,
page: 1,
page_size: 1,
});
const notification = response.items?.[0];
if (!notification) return;
uni.showModal({
title: notification.title || '通知提醒',
content: notification.body || '你有一条新的未读通知',
showCancel: false,
confirmText: '知道了',
});
// 弹窗已经把通知内容呈现给用户,立即同步已读状态,不要求额外点击“已读”。
decreaseNotificationUnreadCount();
void markNotificationRead(notification.id).catch((error) => {
console.error(`标记通知 ${notification.id} 已读失败:`, error);
});
}
catch (error) {
console.error('加载未读通知提醒失败:', error);
}
}
async function loadNotificationData() {
await Promise.all([
loadNotificationUnreadCount(),
showUnreadNotificationReminder(),
]);
}
// 跳转页面
function navigateTo(path: string) {
uni.navigateTo({ url: path });
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi();
}
catch (error) {
console.error('登出接口调用失败:', error);
}
finally {
// 清除本地登录状态
clearToken();
uni.removeStorageSync('refresh_token');
uni.removeStorageSync('user_info');
uni.removeStorageSync(NOTIFICATION_REMINDER_PENDING_KEY);
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
});
}
}
},
});
}
onMounted(async () => {
await loadHomeData();
if (userInfo.value) {
await nextTick();
await loadNotificationData();
}
});
</script>
<style scoped>
.center {
display: flex;

View File

@@ -10,7 +10,7 @@
<view
v-for="notification in notifications"
:key="notification.id"
class="rounded-16px bg-white p-4 shadow-[0_2px_10px_rgba(0,0,0,0.04)]"
class="rounded-16px bg-white p-4"
>
<view class="flex items-start justify-between gap-3">
<view class="min-w-0 flex flex-1 items-center gap-2">
@@ -99,7 +99,7 @@ function formatTime(time: string) {
function getSeverityClass(severity: NotificationSeverity) {
const classes: Record<NotificationSeverity, string> = {
info: 'bg-[#e6f4ff] text-[#1677ff]',
info: 'bg-[#edf5ff] text-[#66a6ff]',
warning: 'bg-[#fff7e6] text-[#d46b08]',
error: 'bg-[#fff2f0] text-[#cf1322]',
critical: 'bg-[#fff1f0] text-[#a8071a]',

View File

@@ -1,128 +1,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getUserInfo, logout as logoutApi } from '@/api/auth'
import { clearToken } from '@/utils/auth'
import type { UserInfo } from '@/api/auth'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
// 加载状态
const loading = ref(false)
// 用户类型文本(优先使用接口返回的 user_type_name
const userTypeText = computed(() => {
if (!userInfo.value)
return ''
return userInfo.value.user_type_name || '未知'
})
// 用户类型颜色
const userTypeColor = computed(() => {
if (!userInfo.value)
return { bg: '#f5f5f5', text: '#999' }
const colorMap: Record<number, { bg: string, text: string }> = {
1: { bg: '#ffebee', text: '#c62828' },
2: { bg: '#e3f2fd', text: '#1976d2' },
3: { bg: '#e8f5e9', text: '#2e7d32' },
4: { bg: '#fff3e0', text: '#e65100' },
}
return colorMap[userInfo.value.user_type] || { bg: '#f5f5f5', text: '#999' }
})
// 页面加载
onMounted(() => {
loadUserInfo()
})
// 加载用户信息
async function loadUserInfo() {
loading.value = true
try {
// 调用 API 获取用户信息
const data = await getUserInfo()
userInfo.value = data
}
catch (error: any) {
console.error('获取用户信息失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
// 修改密码
function changePassword() {
uni.navigateTo({
url: '/pages/agent-system/change-password/index',
})
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi()
}
catch (error) {
console.error('登出接口调用失败:', error)
}
finally {
// 清除本地登录状态
clearToken()
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('user_info')
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
})
}
}
},
})
}
// 复制文本
function copyText(text: string, label: string) {
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({
title: `已复制${label}`,
icon: 'success',
})
},
})
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<view class="min-h-screen bg-[#fafafa] pb-safe">
<!-- 顶部背景区域 -->
<view class="bg-[#212121]">
<!-- 用户头像卡片 -->
<view v-if="userInfo" class="px-4 pt-5 pb-6">
<view v-if="userInfo" class="px-4 pb-6 pt-5">
<view class="center flex-col">
<!-- 头像 -->
<view class="w-80px h-80px rounded-full bg-[#ffffff25] center mb-3">
<view class="mb-3 h-80px w-80px center rounded-full bg-[#ffffff25]">
<i class="i-mdi-account text-48px text-white" />
</view>
<!-- 用户名 -->
<text class="text-20px font-700 text-white block mb-1">
<text class="mb-1 block text-20px text-white font-700">
{{ userInfo.username }}
</text>
@@ -132,7 +21,7 @@ function copyText(text: string, label: string) {
backgroundColor: userTypeColor.bg,
color: userTypeColor.text,
}"
class="px-3 py-1 rounded-8px text-12px font-600"
class="rounded-8px px-3 py-1 text-12px font-600"
>
{{ userTypeText }}
</view>
@@ -141,16 +30,16 @@ function copyText(text: string, label: string) {
</view>
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<view v-if="loading" class="center pt-20">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 内容区域 -->
<view v-else-if="userInfo" class="px-4 pt-4">
<!-- 个人信息 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-3">
<view class="px-4 py-3 border-b-1px border-[#f5f5f5]">
<text class="text-14px font-600 text-[#212121]">
<view class="mb-3 overflow-hidden rounded-16px bg-white">
<view class="border-b-1px border-[#f5f5f5] px-4 py-3">
<text class="text-14px text-[#212121] font-600">
个人信息
</text>
</view>
@@ -158,37 +47,47 @@ function copyText(text: string, label: string) {
<view class="px-4 py-2">
<!-- 用户名 -->
<view
class="py-3 flex items-center justify-between border-b-1px border-[#f5f5f5]"
class="flex items-center justify-between border-b-1px border-[#f5f5f5] py-3"
@click="copyText(userInfo.username, '用户名')"
>
<text class="text-14px text-[#999]">用户名</text>
<text class="text-14px text-[#999]">
用户名
</text>
<view class="flex items-center gap-2">
<text class="text-14px text-[#212121]">{{ userInfo.username }}</text>
<text class="text-14px text-[#212121]">
{{ userInfo.username }}
</text>
<i class="i-mdi-content-copy text-16px text-[#999]" />
</view>
</view>
<!-- 手机号 -->
<view
class="py-3 flex items-center justify-between border-b-1px border-[#f5f5f5]"
class="flex items-center justify-between border-b-1px border-[#f5f5f5] py-3"
@click="copyText(userInfo.phone, '手机号')"
>
<text class="text-14px text-[#999]">手机号</text>
<text class="text-14px text-[#999]">
手机号
</text>
<view class="flex items-center gap-2">
<text class="text-14px text-[#212121]">{{ userInfo.phone }}</text>
<text class="text-14px text-[#212121]">
{{ userInfo.phone }}
</text>
<i class="i-mdi-content-copy text-16px text-[#999]" />
</view>
</view>
<!-- 用户类型 -->
<view class="py-3 flex items-center justify-between">
<text class="text-14px text-[#999]">用户类型</text>
<view class="flex items-center justify-between py-3">
<text class="text-14px text-[#999]">
用户类型
</text>
<view
:style="{
backgroundColor: userTypeColor.bg,
color: userTypeColor.text,
}"
class="px-3 py-1 rounded-6px text-12px font-600"
class="rounded-6px px-3 py-1 text-12px font-600"
>
{{ userInfo.user_type_name }}
</view>
@@ -200,32 +99,22 @@ function copyText(text: string, label: string) {
<view class="space-y-3">
<!-- 修改密码 -->
<view
class="
bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)]
py-3 center gap-2
transition-all duration-200
active:bg-[#f5f5f5]
"
class="center gap-2 rounded-16px bg-white py-3 transition-all duration-200 active:bg-[#f5f5f5]"
@click="changePassword"
>
<i class="i-mdi-lock-reset text-18px text-[#666]" />
<text class="text-15px font-600 text-[#666]">
<text class="text-15px text-[#666] font-600">
修改密码
</text>
</view>
<!-- 退出登录 -->
<view
class="
bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)]
py-3 center gap-2
transition-all duration-200
active:bg-[#ffebee]
"
class="center gap-2 rounded-16px bg-white py-3 transition-all duration-200 active:bg-[#ffebee]"
@click="logout"
>
<i class="i-mdi-logout text-18px text-[#c62828]" />
<text class="text-15px font-600 text-[#c62828]">
<text class="text-15px text-[#c62828] font-600">
退出登录
</text>
</view>
@@ -237,6 +126,117 @@ function copyText(text: string, label: string) {
</view>
</template>
<script setup lang="ts">
import type { UserInfo } from '@/api/auth';
import { computed, onMounted, ref } from 'vue';
import { getUserInfo, logout as logoutApi } from '@/api/auth';
import { clearToken } from '@/utils/auth';
// 用户信息
const userInfo = ref<UserInfo | null>(null);
// 加载状态
const loading = ref(false);
// 用户类型文本(优先使用接口返回的 user_type_name
const userTypeText = computed(() => {
if (!userInfo.value)
return '';
return userInfo.value.user_type_name || '未知';
});
// 用户类型颜色
const userTypeColor = computed(() => {
if (!userInfo.value)
return { bg: '#f5f5f5', text: '#999' };
const colorMap: Record<number, { bg: string; text: string }> = {
1: { bg: '#ffebee', text: '#c62828' },
2: { bg: '#e3f2fd', text: '#1976d2' },
3: { bg: '#e8f5e9', text: '#2e7d32' },
4: { bg: '#fff3e0', text: '#e65100' },
};
return colorMap[userInfo.value.user_type] || { bg: '#f5f5f5', text: '#999' };
});
// 页面加载
onMounted(() => {
loadUserInfo();
});
// 加载用户信息
async function loadUserInfo() {
loading.value = true;
try {
// 调用 API 获取用户信息
const data = await getUserInfo();
userInfo.value = data;
}
catch (error: any) {
console.error('获取用户信息失败:', error);
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message);
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false;
}
}
// 修改密码
function changePassword() {
uni.navigateTo({
url: '/pages/agent-system/change-password/index',
});
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi();
}
catch (error) {
console.error('登出接口调用失败:', error);
}
finally {
// 清除本地登录状态
clearToken();
uni.removeStorageSync('refresh_token');
uni.removeStorageSync('user_info');
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
});
}
}
},
});
}
// 复制文本
function copyText(text: string, label: string) {
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({
title: `已复制${label}`,
icon: 'success',
});
},
});
}
</script>
<style scoped>
.center {
display: flex;

View File

@@ -1,223 +1,12 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getFundSummary } from '@/api/commission'
import type { FundSummary } from '@/api/commission'
import { createWithdrawal, getWithdrawalRecords } from '@/api/withdrawal'
import type { WithdrawalRecord } from '@/api/withdrawal'
import SimplePicker from '@/components/SimplePicker.vue'
type LoginUserInfo = {
shop_id?: number
username?: string
}
const commissionSummary = ref<FundSummary | null>(null)
const shopId = ref<number>(0)
const withdrawAmount = ref('')
const accountName = ref('')
const accountNumber = ref('')
const withdrawRecords = ref<WithdrawalRecord[]>([])
const activeTab = ref<'apply' | 'records'>('apply')
const loading = ref(false)
const submitting = ref(false)
const loadingRecords = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const statusFilter = ref<number | undefined>(undefined)
const showStatusPicker = ref(false)
const MIN_WITHDRAW_AMOUNT = 100
const statusOptions = [
{ label: '全部状态', value: undefined },
{ label: '审核中', value: 1 },
{ label: '已通过', value: 2 },
{ label: '已拒绝', value: 3 },
{ label: '已到账', value: 4 },
]
const selectedStatusName = computed(() => {
if (statusFilter.value === undefined) return '状态'
const option = statusOptions.find(o => o.value === statusFilter.value)
return option?.label || '状态'
})
const tabs: Array<{ key: 'apply' | 'records', label: string }> = [
{ key: 'apply', label: '申请提现' },
{ key: 'records', label: '提现记录' },
]
const availableAmount = computed(() => {
if (!commissionSummary.value) return 0
return commissionSummary.value.available_commission / 100
})
const canWithdrawByMinAmount = computed(() => availableAmount.value >= MIN_WITHDRAW_AMOUNT)
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
}
function getStatusText(status: number, statusName?: string) {
return statusName || (status === 1 ? '审核中' : status === 2 ? '已通过' : status === 3 ? '已拒绝' : status === 4 ? '已到账' : '未知')
}
function getStatusColor(status: number) {
const map: Record<number, { bg: string, text: string }> = {
1: { bg: '#fff3e0', text: '#e65100' },
2: { bg: '#e8f5e9', text: '#2e7d32' },
3: { bg: '#ffebee', text: '#c62828' },
4: { bg: '#e3f2fd', text: '#1565c0' },
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
function formatTime(time: string) {
if (!time) return '-'
const date = new Date(time)
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as LoginUserInfo | undefined
}
onMounted(() => {
const userInfo = getLoginUserInfo()
if (userInfo?.shop_id) {
shopId.value = userInfo.shop_id
}
loadCommissionSummary()
loadWithdrawRecords()
})
async function loadCommissionSummary() {
loading.value = true
try {
const userInfo = getLoginUserInfo()
commissionSummary.value = await getFundSummary(userInfo?.username ? { username: userInfo.username } : undefined)
withdrawAmount.value = availableAmount.value.toFixed(2)
} catch (error) {
console.error('加载佣金概览失败:', error)
} finally {
loading.value = false
}
}
async function loadWithdrawRecords(isRefresh = false) {
if (!shopId.value) return
if (isRefresh) {
page.value = 1
withdrawRecords.value = []
}
loadingRecords.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
const response = await getWithdrawalRecords(shopId.value, params)
if (page.value === 1) {
withdrawRecords.value = response.items || []
} else {
withdrawRecords.value.push(...(response.items || []))
}
total.value = response.total || 0
} catch (error) {
console.error('加载提现记录失败:', error)
} finally {
loadingRecords.value = false
}
}
function setQuickAmount(amount: number) {
withdrawAmount.value = amount.toString()
}
function setAllAmount() {
withdrawAmount.value = availableAmount.value.toFixed(2)
}
async function handleSubmit() {
const amount = parseFloat(withdrawAmount.value)
if (!amount || amount <= 0) {
uni.$u.toast('请输入提现金额')
return
}
if (amount < MIN_WITHDRAW_AMOUNT) {
uni.$u.toast(`${MIN_WITHDRAW_AMOUNT}元才可提现`)
return
}
if (amount > availableAmount.value) {
uni.$u.toast('提现金额超过可用余额')
return
}
const name = accountName.value.trim()
if (!name) {
uni.$u.toast('请输入收款人姓名')
return
}
const account = accountNumber.value.trim()
if (!account) {
uni.$u.toast('请输入支付宝账号')
return
}
submitting.value = true
try {
await createWithdrawal(shopId.value, {
account_name: name,
account_number: account,
amount: Math.round(amount * 100),
withdrawal_method: 'alipay',
})
uni.$u.toast('提现申请已提交')
withdrawAmount.value = ''
accountName.value = ''
accountNumber.value = ''
await loadCommissionSummary()
page.value = 1
await loadWithdrawRecords()
activeTab.value = 'records'
} catch (error) {
console.error('提现申请失败:', error)
} finally {
submitting.value = false
}
}
function handleTabChange(tab: 'apply' | 'records') {
activeTab.value = tab
if (tab === 'records') {
loadWithdrawRecords(true)
}
}
function onStatusConfirm(option: { label: string, value: any }) {
statusFilter.value = option.value
loadWithdrawRecords(true)
}
function loadMore() {
if (withdrawRecords.value.length < total.value) {
page.value++
loadWithdrawRecords()
}
}
</script>
<template>
<view class="bg-page min-h-screen">
<!-- 顶部搜索区域 -->
<view class="bg-white px-4 pt-4 pb-3">
<view class="bg-white px-4 pb-3 pt-4">
<view class="flex rounded-full bg-[#f1f5f9] p-1">
<view
v-for="tab in tabs"
:key="tab.key"
class="flex-1 text-center py-2 rounded-full text-14px font-medium transition-all"
class="flex-1 rounded-full py-2 text-center text-14px font-medium transition-all"
:class="activeTab === tab.key ? 'bg-white text-[#212121] shadow-sm' : 'text-[#999]'"
@click="handleTabChange(tab.key)"
>
@@ -231,42 +20,48 @@ function loadMore() {
<!-- 申请提现 -->
<view v-if="activeTab === 'apply'">
<!-- 可提现金额卡片 -->
<view class="bg-white rounded-16px p-5 mt-4 shadow-[0_2px_12px_rgba(0,0,0,0.08)]">
<view class="mt-4 rounded-16px bg-white p-5">
<view class="flex items-center justify-between">
<view>
<text class="text-12px text-[#999]">可提现金额</text>
<text class="text-32px font-700 text-[#212121] block mt-1">
<text class="text-12px text-[#999]">
可提现金额
</text>
<text class="mt-1 block text-32px text-[#212121] font-700">
¥{{ availableAmount.toFixed(2) }}
</text>
</view>
<image src="@/static/icons/提现管理.png" class="w-12 h-12 rounded-12px" mode="aspectFit" />
<image src="@/static/icons/提现管理.png" class="h-12 w-12 rounded-12px" mode="aspectFit" />
</view>
</view>
<!-- 提现金额输入 -->
<view class="bg-white rounded-16px p-4 mt-3">
<text class="text-12px text-[#999]">提现金额</text>
<view class="flex items-center mt-2">
<text class="text-24px text-[#212121] mr-2">¥</text>
<view class="mt-3 rounded-16px bg-white p-4">
<text class="text-12px text-[#999]">
提现金额
</text>
<view class="mt-2 flex items-center">
<text class="mr-2 text-24px text-[#212121]">
¥
</text>
<input
v-model="withdrawAmount"
type="digit"
class="flex-1 text-24px text-[#212121]"
placeholder="0.00"
/>
>
</view>
<view class="flex gap-2 mt-3">
<view class="mt-3 flex gap-2">
<view
v-for="amount in [100, 500, 1000]"
:key="amount"
class="px-4 py-2 rounded-full text-13px"
class="rounded-full px-4 py-2 text-13px"
:class="withdrawAmount == amount.toString() ? 'bg-brand text-white' : 'bg-[#f1f5f9] text-[#666]'"
@click="setQuickAmount(amount)"
>
¥{{ amount }}
</view>
<view
class="px-4 py-2 rounded-full text-13px"
class="rounded-full px-4 py-2 text-13px"
:class="withdrawAmount == availableAmount.toFixed(2) ? 'bg-brand text-white' : 'bg-[#f1f5f9] text-[#666]'"
@click="setAllAmount"
>
@@ -276,25 +71,29 @@ function loadMore() {
</view>
<!-- 收款信息 -->
<view class="bg-white rounded-16px p-4 mt-3">
<text class="text-12px text-[#999]">收款人姓名</text>
<view class="mt-3 rounded-16px bg-white p-4">
<text class="text-12px text-[#999]">
收款人姓名
</text>
<input
v-model="accountName"
class="w-full text-16px text-[#212121] mt-2"
class="mt-2 w-full text-16px text-[#212121]"
placeholder="请输入姓名"
/>
>
</view>
<view class="bg-white rounded-16px p-4 mt-3">
<text class="text-12px text-[#999]">支付宝账号</text>
<view class="mt-3 rounded-16px bg-white p-4">
<text class="text-12px text-[#999]">
支付宝账号
</text>
<input
v-model="accountNumber"
class="w-full text-16px text-[#212121] mt-2"
class="mt-2 w-full text-16px text-[#212121]"
placeholder="请输入账号"
/>
>
</view>
<view class="mt-6 mb-3 flex items-center gap-1.5 px-3 py-2 rounded-12px bg-[var(--color-warning-bg)]">
<view class="mb-3 mt-6 flex items-center gap-1.5 rounded-12px bg-[var(--color-warning-bg)] px-3 py-2">
<i class="i-mdi-information-outline text-14px text-warning" />
<text class="text-12px" :class="canWithdrawByMinAmount ? 'text-warning' : 'text-error'">
满100元才可提现
@@ -303,7 +102,7 @@ function loadMore() {
<!-- 提交按钮 -->
<view
class="bg-brand rounded-full py-4 text-center text-16px font-600 text-white"
class="bg-brand rounded-full py-4 text-center text-16px text-white font-600"
:class="{ 'opacity-50': submitting || !canWithdrawByMinAmount }"
@click="handleSubmit"
>
@@ -313,61 +112,83 @@ function loadMore() {
<!-- 提现记录 -->
<view v-else>
<view class="flex items-center gap-2 mt-4 mb-3">
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full" @click="showStatusPicker = true">
<text class="text-12px text-[#64748b]">{{ selectedStatusName }}</text>
<view class="mb-3 mt-4 flex items-center gap-2">
<view class="flex items-center gap-1 rounded-full bg-[#f8fafc] px-3 py-1.5" @click="showStatusPicker = true">
<text class="text-12px text-[#64748b]">
{{ selectedStatusName }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view
v-if="statusFilter !== undefined"
class="flex items-center gap-1 px-2 py-1.5 rounded-full bg-[#ff4d4f]/10 text-[#ff4d4f]"
class="flex items-center gap-1 rounded-full bg-[#ff4d4f]/10 px-2 py-1.5 text-[#ff4d4f]"
@click="statusFilter = undefined; loadWithdrawRecords(true)"
>
<i class="i-mdi-close text-12px" />
<text class="text-12px font-medium">清除</text>
<text class="text-12px font-medium">
清除
</text>
</view>
</view>
<view v-if="loadingRecords && page === 1" class="pt-20 flex justify-center">
<text class="text-14px text-[#64748b]">加载中...</text>
<view v-if="loadingRecords && page === 1" class="flex justify-center pt-20">
<text class="text-14px text-[#64748b]">
加载中...
</text>
</view>
<view v-else-if="withdrawRecords.length > 0" class="space-y-3">
<view
v-for="record in withdrawRecords"
:key="record.id"
class="bg-white rounded-12px p-4"
class="rounded-12px bg-white p-4"
>
<view class="flex items-center justify-between mb-2">
<text class="text-12px text-[#999]">{{ formatTime(record.created_at) }}</text>
<view class="mb-2 flex items-center justify-between">
<text class="text-12px text-[#999]">
{{ formatTime(record.created_at) }}
</text>
<view
:style="{ backgroundColor: getStatusColor(record.status).bg, color: getStatusColor(record.status).text }"
class="px-2 py-0.5 rounded text-11px font-500"
class="rounded px-2 py-0.5 text-11px font-500"
>
{{ getStatusText(record.status, record.status_name) }}
</view>
</view>
<view class="flex items-center justify-between">
<text class="text-14px text-[#666]">{{ record.account_name }}</text>
<text class="text-18px font-600 text-[#ff4d4f]">-{{ formatAmount(record.amount) }}</text>
<text class="text-14px text-[#666]">
{{ record.account_name }}
</text>
<text class="text-18px text-[#ff4d4f] font-600">
-{{ formatAmount(record.amount) }}
</text>
</view>
<view v-if="record.status === 3 && record.reject_reason" class="mt-2 pt-2 border-t border-[#f0f0f0]">
<text class="text-12px text-[#ff4d4f]">拒绝原因: {{ record.reject_reason }}</text>
<view v-if="record.status === 3 && record.reject_reason" class="mt-2 border-t border-[#f0f0f0] pt-2">
<text class="text-12px text-[#ff4d4f]">
拒绝原因: {{ record.reject_reason }}
</text>
</view>
</view>
<view v-if="withdrawRecords.length < total" class="py-4 text-center">
<text v-if="loadingRecords" class="text-12px text-[#64748b]">加载中...</text>
<text v-else class="text-12px text-brand" @click="loadMore">加载更多</text>
<text v-if="loadingRecords" class="text-12px text-[#64748b]">
加载中...
</text>
<text v-else class="text-brand text-12px" @click="loadMore">
加载更多
</text>
</view>
<view v-else class="py-4 text-center">
<text class="text-12px text-[#cbd5e1]">没有更多了</text>
<text class="text-12px text-[#cbd5e1]">
没有更多了
</text>
</view>
</view>
<view v-else class="pt-16 flex flex-col items-center">
<i class="i-mdi-file-document-outline text-5xl text-[#e0e0e0] mb-3" />
<text class="text-14px text-[#999]">暂无提现记录</text>
<view v-else class="flex flex-col items-center pt-16">
<i class="i-mdi-file-document-outline mb-3 text-5xl text-[#e0e0e0]" />
<text class="text-14px text-[#999]">
暂无提现记录
</text>
</view>
</view>
</view>
@@ -381,6 +202,223 @@ function loadMore() {
</view>
</template>
<script setup lang="ts">
import type { FundSummary } from '@/api/commission';
import type { WithdrawalRecord } from '@/api/withdrawal';
import { computed, onMounted, ref } from 'vue';
import { getFundSummary } from '@/api/commission';
import { createWithdrawal, getWithdrawalRecords } from '@/api/withdrawal';
import SimplePicker from '@/components/SimplePicker.vue';
interface LoginUserInfo {
shop_id?: number;
username?: string;
}
const commissionSummary = ref<FundSummary | null>(null);
const shopId = ref<number>(0);
const withdrawAmount = ref('');
const accountName = ref('');
const accountNumber = ref('');
const withdrawRecords = ref<WithdrawalRecord[]>([]);
const activeTab = ref<'apply' | 'records'>('apply');
const loading = ref(false);
const submitting = ref(false);
const loadingRecords = ref(false);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const statusFilter = ref<number | undefined>(undefined);
const showStatusPicker = ref(false);
const MIN_WITHDRAW_AMOUNT = 100;
const statusOptions = [
{ label: '全部状态', value: undefined },
{ label: '审核中', value: 1 },
{ label: '已通过', value: 2 },
{ label: '已拒绝', value: 3 },
{ label: '已到账', value: 4 },
];
const selectedStatusName = computed(() => {
if (statusFilter.value === undefined) return '状态';
const option = statusOptions.find(o => o.value === statusFilter.value);
return option?.label || '状态';
});
const tabs: Array<{ key: 'apply' | 'records'; label: string }> = [
{ key: 'apply', label: '申请提现' },
{ key: 'records', label: '提现记录' },
];
const availableAmount = computed(() => {
if (!commissionSummary.value) return 0;
return commissionSummary.value.available_commission / 100;
});
const canWithdrawByMinAmount = computed(() => availableAmount.value >= MIN_WITHDRAW_AMOUNT);
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2);
const parts = yuan.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `¥${parts.join('.')}`;
}
function getStatusText(status: number, statusName?: string) {
return statusName || (status === 1 ? '审核中' : status === 2 ? '已通过' : status === 3 ? '已拒绝' : status === 4 ? '已到账' : '未知');
}
function getStatusColor(status: number) {
const map: Record<number, { bg: string; text: string }> = {
1: { bg: '#fff3e0', text: '#e65100' },
2: { bg: '#e8f5e9', text: '#2e7d32' },
3: { bg: '#ffebee', text: '#c62828' },
4: { bg: '#e3f2fd', text: '#1565c0' },
};
return map[status] || { bg: '#f5f5f5', text: '#999' };
}
function formatTime(time: string) {
if (!time) return '-';
const date = new Date(time);
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as LoginUserInfo | undefined;
}
onMounted(() => {
const userInfo = getLoginUserInfo();
if (userInfo?.shop_id) {
shopId.value = userInfo.shop_id;
}
loadCommissionSummary();
loadWithdrawRecords();
});
async function loadCommissionSummary() {
loading.value = true;
try {
const userInfo = getLoginUserInfo();
commissionSummary.value = await getFundSummary(userInfo?.username ? { username: userInfo.username } : undefined);
withdrawAmount.value = availableAmount.value.toFixed(2);
}
catch (error) {
console.error('加载佣金概览失败:', error);
}
finally {
loading.value = false;
}
}
async function loadWithdrawRecords(isRefresh = false) {
if (!shopId.value) return;
if (isRefresh) {
page.value = 1;
withdrawRecords.value = [];
}
loadingRecords.value = true;
try {
const params: any = { page: page.value, page_size: pageSize };
if (statusFilter.value !== undefined) {
params.status = statusFilter.value;
}
const response = await getWithdrawalRecords(shopId.value, params);
if (page.value === 1) {
withdrawRecords.value = response.items || [];
}
else {
withdrawRecords.value.push(...(response.items || []));
}
total.value = response.total || 0;
}
catch (error) {
console.error('加载提现记录失败:', error);
}
finally {
loadingRecords.value = false;
}
}
function setQuickAmount(amount: number) {
withdrawAmount.value = amount.toString();
}
function setAllAmount() {
withdrawAmount.value = availableAmount.value.toFixed(2);
}
async function handleSubmit() {
const amount = Number.parseFloat(withdrawAmount.value);
if (!amount || amount <= 0) {
uni.$u.toast('请输入提现金额');
return;
}
if (amount < MIN_WITHDRAW_AMOUNT) {
uni.$u.toast(`${MIN_WITHDRAW_AMOUNT}元才可提现`);
return;
}
if (amount > availableAmount.value) {
uni.$u.toast('提现金额超过可用余额');
return;
}
const name = accountName.value.trim();
if (!name) {
uni.$u.toast('请输入收款人姓名');
return;
}
const account = accountNumber.value.trim();
if (!account) {
uni.$u.toast('请输入支付宝账号');
return;
}
submitting.value = true;
try {
await createWithdrawal(shopId.value, {
account_name: name,
account_number: account,
amount: Math.round(amount * 100),
withdrawal_method: 'alipay',
});
uni.$u.toast('提现申请已提交');
withdrawAmount.value = '';
accountName.value = '';
accountNumber.value = '';
await loadCommissionSummary();
page.value = 1;
await loadWithdrawRecords();
activeTab.value = 'records';
}
catch (error) {
console.error('提现申请失败:', error);
}
finally {
submitting.value = false;
}
}
function handleTabChange(tab: 'apply' | 'records') {
activeTab.value = tab;
if (tab === 'records') {
loadWithdrawRecords(true);
}
}
function onStatusConfirm(option: { label: string; value: any }) {
statusFilter.value = option.value;
loadWithdrawRecords(true);
}
function loadMore() {
if (withdrawRecords.value.length < total.value) {
page.value++;
loadWithdrawRecords();
}
}
</script>
<style scoped>
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;

View File

@@ -12,8 +12,12 @@
<!-- 顶部品牌区 -->
<view class="brand-header">
<view class="welcome-info">
<view class="welcome-title">欢迎回来</view>
<view class="welcome-desc">请输入您的账号密码进行登录</view>
<view class="welcome-title">
欢迎回来
</view>
<view class="welcome-desc">
请输入您的账号密码进行登录
</view>
</view>
</view>
@@ -64,9 +68,9 @@
</template>
<script setup lang="ts">
import { HOME_PATH, isTabBarPath, LOGIN_PATH, removeQueryString } from '@/router';
import { setToken } from '@/utils/auth';
import { login } from '@/api/auth';
import { HOME_PATH, LOGIN_PATH, removeQueryString } from '@/router';
import { setToken } from '@/utils/auth';
const account = ref<string>('');
const password = ref<string>('');
@@ -155,11 +159,13 @@ async function submit() {
url: '/pages/agent-system/home/index',
});
}, 1500);
} catch (error: any) {
}
catch (error: any) {
console.error('登录失败:', error);
// 拦截器已经显示了后端返回的错误信息,这里不需要再次提示
// 如果需要额外处理,可以访问 error.data.msg 获取错误信息
} finally {
}
finally {
loading.value = false;
}
}
@@ -205,7 +211,7 @@ onLoad((options: any) => {
width: 100%;
height: 100%;
background:
linear-gradient(90deg, transparent 0%, rgba(22, 119, 255, 0.02) 50%, transparent 100%);
linear-gradient(90deg, transparent 0%, rgba(102, 166, 255, 0.02) 50%, transparent 100%);
animation: scanline 8s linear infinite;
}
@@ -218,7 +224,7 @@ onLoad((options: any) => {
height: 800rpx;
top: -300rpx;
right: -200rpx;
background: radial-gradient(circle, rgba(22, 119, 255, 0.08), transparent 60%);
background: radial-gradient(circle, rgba(102, 166, 255, 0.08), transparent 60%);
border-radius: 50%;
animation: pulse1 10s infinite ease-in-out;
}
@@ -228,7 +234,7 @@ onLoad((options: any) => {
height: 600rpx;
bottom: -200rpx;
left: -150rpx;
background: radial-gradient(circle, rgba(22, 119, 255, 0.06), transparent 60%);
background: radial-gradient(circle, rgba(102, 166, 255, 0.06), transparent 60%);
border-radius: 50%;
animation: pulse2 12s infinite ease-in-out;
}
@@ -238,7 +244,7 @@ onLoad((options: any) => {
height: 400rpx;
top: 50%;
right: -100rpx;
background: radial-gradient(circle, rgba(22, 119, 255, 0.04), transparent 60%);
background: radial-gradient(circle, rgba(102, 166, 255, 0.04), transparent 60%);
border-radius: 50%;
animation: pulse3 14s infinite ease-in-out;
}
@@ -294,7 +300,6 @@ onLoad((options: any) => {
padding: 60rpx 48rpx;
background: #ffffff;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08), 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
z-index: 1;
animation: cardFadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@@ -388,7 +393,7 @@ onLoad((options: any) => {
&.focused {
background: #ffffff;
border-color: var(--brand-primary);
box-shadow: 0 0 0 4rpx rgba(22, 119, 255, 0.08);
box-shadow: 0 0 0 4rpx rgba(102, 166, 255, 0.08);
transform: translateY(-2rpx);
}
@@ -443,14 +448,14 @@ onLoad((options: any) => {
font-size: 32rpx;
font-weight: 500;
color: #fff !important;
background: #ff7c24 !important;
background: #66a6ff !important;
border: none;
border-radius: 25rpx;
letter-spacing: 2rpx;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(255, 124, 36, 0.3);
box-shadow: 0 4rpx 16rpx rgba(102, 166, 255, 0.3);
padding: 0;
margin: 0;
line-height: 1;
@@ -471,7 +476,7 @@ onLoad((options: any) => {
&:active:not([disabled]) {
transform: scale(0.98);
box-shadow: 0 2rpx 10rpx rgba(255, 124, 36, 0.35);
box-shadow: 0 2rpx 10rpx rgba(102, 166, 255, 0.35);
}
&::after {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
src/static/icons/右边.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
src/static/icons/复制.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -6,11 +6,11 @@
:root,
page {
/* === 品牌色 Brand Colors === */
--brand-primary: #2f7d32; /* 主色 */
--brand-primary-light: #5a9f5d; /* 主色浅 */
--brand-primary-dark: #245f27; /* 主色深 */
--brand-primary: #66a6ff; /* 主色 */
--brand-primary-light: #8fc0ff; /* 主色浅 */
--brand-primary-dark: #408de8; /* 主色深 */
--brand-secondary: #1f2937; /* 辅色 */
--brand-tertiary: #f4fbf5; /* 次色 */
--brand-tertiary: #f1f6fc; /* 次色 */
--brand-accent: #ffffff; /* 强调白 */
/* === 语义色 Semantic Colors === */
@@ -31,16 +31,16 @@ page {
--text-inverse: #ffffff;
/* === 背景色 Background Colors === */
--bg-page: var(--brand-tertiary);
--bg-page: #f4f6fa;
--bg-container: #ffffff;
--bg-elevated: #ffffff;
--bg-muted: #f8faf8;
--bg-muted: #f7f9fc;
--bg-overlay: rgba(31, 41, 55, 0.45);
/* === 边框色 Border Colors === */
--border-primary: #e3ece4;
--border-secondary: #edf2ee;
--border-light: #f7faf7;
--border-primary: #e1eaf5;
--border-secondary: #edf2f8;
--border-light: #f7f9fc;
/* === 阴影 Shadows === */
--shadow-xs: 0 1px 2px 0 rgba(31, 41, 55, 0.04);
@@ -48,7 +48,7 @@ page {
--shadow-md: 0 4px 12px 0 rgba(31, 41, 55, 0.08);
--shadow-lg: 0 8px 20px 0 rgba(31, 41, 55, 0.12);
--shadow-xl: 0 12px 28px 0 rgba(31, 41, 55, 0.16);
--shadow-brand: 0 6px 18px 0 rgba(47, 125, 50, 0.18);
--shadow-brand: 0 6px 18px 0 rgba(102, 166, 255, 0.18);
/* === 圆角 Radius === */
--radius-xs: 4px;
@@ -118,8 +118,8 @@ page {
/* === 暗色主题 Dark Theme === */
.theme-dark {
--brand-primary-light: #74b377;
--brand-primary-dark: #245f27;
--brand-primary-light: #8fc0ff;
--brand-primary-dark: #408de8;
--color-warning: #f0b451;
--color-error: #ef7f7f;
@@ -144,7 +144,7 @@ page {
--shadow-md: 0 4px 12px 0 rgba(0, 0, 0, 0.35);
--shadow-lg: 0 8px 20px 0 rgba(0, 0, 0, 0.45);
--shadow-xl: 0 12px 28px 0 rgba(0, 0, 0, 0.55);
--shadow-brand: 0 6px 18px 0 rgba(47, 125, 50, 0.22);
--shadow-brand: 0 6px 18px 0 rgba(102, 166, 255, 0.22);
}
/* === 全局重置 Global Resets === */
@@ -178,9 +178,9 @@ page {
.bg-brand-dark { background-color: var(--brand-primary-dark); }
.bg-gradient-brand { background: linear-gradient(135deg, var(--brand-primary), var(--brand-primary-light)); }
.shadow-brand-colored { box-shadow: 0 8px 22px rgba(47, 125, 50, 0.22); }
.shadow-brand-sm { box-shadow: 0 4px 14px rgba(47, 125, 50, 0.14); }
.shadow-brand-xs { box-shadow: 0 2px 8px rgba(47, 125, 50, 0.16); }
.shadow-brand-colored { box-shadow: 0 8px 22px rgba(102, 166, 255, 0.22); }
.shadow-brand-sm { box-shadow: 0 4px 14px rgba(102, 166, 255, 0.14); }
.shadow-brand-xs { box-shadow: 0 2px 8px rgba(102, 166, 255, 0.16); }
.border-brand { border-color: var(--brand-primary); }
@@ -207,14 +207,12 @@ page {
.card {
background-color: var(--bg-container);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: var(--space-lg);
transition: var(--transition-base);
}
.card:active {
transform: scale(0.98);
box-shadow: var(--shadow-xs);
}
/* 按钮 */
@@ -262,7 +260,7 @@ page {
.input:focus {
border-color: var(--brand-primary);
outline: none;
box-shadow: 0 0 0 3px rgba(47, 125, 50, 0.12);
box-shadow: 0 0 0 3px rgba(102, 166, 255, 0.12);
}
.input::placeholder {