Initial commit

This commit is contained in:
jiapengyu
2026-07-27 13:37:48 +08:00
commit ecba71e2a5
39123 changed files with 5989154 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
onLaunch(() => {
console.log('App Launch')
})
onShow(() => {
console.log('App Show')
})
onHide(() => {
console.log('App Hide')
})
</script>
<template>
<view>
<slot />
</view>
</template>
<style lang="scss">
@import './uni.scss';
@import '@dcloudio/uni-ui/lib/uni-icons/uniicons.css';
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.container {
padding: 20rpx;
}
.card {
background-color: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.card-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.card-content {
font-size: 28rpx;
color: #666;
line-height: 1.6;
}
.btn-primary {
background-color: #3366ff;
color: #fff;
border-radius: 8rpx;
padding: 16rpx 32rpx;
font-size: 28rpx;
border: none;
cursor: pointer;
&:active {
opacity: 0.9;
}
}
.btn-danger {
background-color: #ff4d4f;
color: #fff;
border-radius: 8rpx;
padding: 16rpx 32rpx;
font-size: 28rpx;
border: none;
cursor: pointer;
&:active {
opacity: 0.9;
}
}
.form-item {
margin-bottom: 24rpx;
.label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
}
input,
textarea,
picker {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
font-size: 28rpx;
background-color: #fff;
box-sizing: border-box;
}
textarea {
height: 200rpx;
padding: 20rpx;
}
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.info {
flex: 1;
.title {
font-size: 30rpx;
color: #333;
margin-bottom: 8rpx;
}
.desc {
font-size: 24rpx;
color: #999;
}
}
.action {
margin-left: 20rpx;
}
}
.empty-state {
text-align: center;
padding: 60rpx 20rpx;
.icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.text {
font-size: 28rpx;
color: #999;
}
}
.price {
color: #ff4d4f;
font-weight: 600;
&::before {
content: '¥';
font-size: 0.8em;
margin-right: 4rpx;
}
}
.badge {
display: inline-flex;
align-items: center;
padding: 4rpx 12rpx;
border-radius: 4rpx;
font-size: 22rpx;
&.success {
background-color: #f6ffed;
color: #52c41a;
}
&.warning {
background-color: #fffbe6;
color: #faad14;
}
&.danger {
background-color: #fff2f0;
color: #ff4d4f;
}
}
</style>
+453
View File
@@ -0,0 +1,453 @@
import type { PriceItem, PriceData } from '@/stores/price'
import type { Order, OrderItem } from '@/stores/order'
import type { UserItem } from '@/stores/user'
import { BASE_URL } from '@/config'
const mockPrices: PriceItem[] = [
{ category: '001', name: '纸类(书本)', unit: 'kg', price: 1.2, icon: 'paper' },
{ category: '002', name: '纸类(纸板)', unit: 'kg', price: 1.0, icon: 'paper' },
{ category: '003', name: '金属(铜)', unit: 'kg', price: 25.0, icon: 'metal' },
{ category: '004', name: '金属(铁)', unit: 'kg', price: 2.5, icon: 'metal' },
{ category: '005', name: '金属(铝)', unit: 'kg', price: 8.0, icon: 'metal' },
{ category: '006', name: '金属(其他)', unit: 'kg', price: 3.0, icon: 'metal' },
{ category: '007', name: '塑料(瓶子)', unit: 'kg', price: 0.5, icon: 'plastic' },
{ category: '008', name: '塑料(其他)', unit: 'kg', price: 3.0, icon: 'plastic' },
{ category: '009', name: '织物', unit: 'kg', price: 0.8, icon: 'fabric' },
{ category: '010', name: '玻璃', unit: 'kg', price: 0.3, icon: 'glass' }
]
const mockUsers: UserItem[] = [
{ id: '1', name: '管理员', phone: '13800138000', role: 'admin', createTime: '2024-01-01' },
{ id: '2', name: '回收员小王', phone: '18698102228', role: 'collector', createTime: '2024-01-15' }
]
export const request = async <T>(url: string, options: UniApp.RequestOptions = {}): Promise<T> => {
const fullUrl = BASE_URL + url
console.log('=== API Request ===')
console.log('URL:', fullUrl)
console.log('Method:', options.method || 'GET')
console.log('Data:', options.data)
return new Promise((resolve, reject) => {
if (typeof window !== 'undefined') {
fetch(fullUrl, {
method: (options.method || 'GET') as string,
headers: {
'Content-Type': 'application/json',
...options.header
},
body: options.data ? JSON.stringify(options.data) : undefined
})
.then(response => {
console.log('Response status:', response.status)
if (response.ok) {
return response.json()
}
throw new Error('Request failed with status ' + response.status)
})
.then(data => {
console.log('Response data:', data)
resolve(data as T)
})
.catch(err => {
console.error('Fetch failed:', err)
reject(err)
})
} else {
uni.request({
url: fullUrl,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...options.header
},
success: (res) => {
console.log('uni.request success:', res.statusCode, res.data)
if (res.statusCode === 200) {
resolve(res.data as T)
} else {
reject(new Error('请求失败'))
}
},
fail: (err) => {
console.error('API request failed, using mock data:', err)
reject(err)
}
})
}
})
}
export const priceApi = {
async getPrices(): Promise<PriceData> {
try {
console.log('Fetching prices from:', BASE_URL + '/prices')
const data = await request<PriceData>('/prices')
console.log('Received prices:', data)
return data
} catch (err) {
console.error('API request failed, using mock data:', err)
return {
date: new Date().toISOString().split('T')[0],
prices: mockPrices
}
}
},
async addPrice(item: PriceItem): Promise<boolean> {
try {
const result = await request<{ success: boolean }>('/prices', {
method: 'POST',
data: item
})
return result.success
} catch {
const cached = uni.getStorageSync('api_prices') || { date: new Date().toISOString().split('T')[0], prices: mockPrices }
cached.prices.push(item)
uni.setStorageSync('api_prices', cached)
return true
}
},
async updatePrice(item: PriceItem, originalCategory?: string): Promise<boolean> {
try {
const category = originalCategory || item.category
const result = await request<{ success: boolean }>(`/prices/${category}`, {
method: 'PUT',
data: item
})
return result.success
} catch {
const cached = uni.getStorageSync('api_prices') || { date: new Date().toISOString().split('T')[0], prices: mockPrices }
const searchCategory = originalCategory || item.category
const index = cached.prices.findIndex(p => p.category === searchCategory)
if (index > -1) {
cached.prices[index] = item
uni.setStorageSync('api_prices', cached)
}
return true
}
},
async deletePrice(category: string): Promise<boolean> {
try {
const result = await request<{ success: boolean }>(`/prices/${category}`, {
method: 'DELETE'
})
return result.success
} catch {
const cached = uni.getStorageSync('api_prices') || { date: new Date().toISOString().split('T')[0], prices: mockPrices }
cached.prices = cached.prices.filter(p => p.category !== category)
uni.setStorageSync('api_prices', cached)
return true
}
}
}
export const orderApi = {
async getOrders(collector?: string): Promise<Order[]> {
try {
const url = collector ? `/orders?collector=${collector}` : '/orders'
const result = await request<{ data: Order[] }>(url)
return result.data || []
} catch {
const cached = uni.getStorageSync('api_orders') || []
if (collector) {
return cached.filter(order => order.collector === collector)
}
return cached
}
},
async addOrder(order: Order): Promise<Order | null> {
try {
const result = await request<{ success: boolean; order: Order }>('/orders', {
method: 'POST',
data: order
})
return result.order || null
} catch {
const cached = uni.getStorageSync('api_orders') || []
const newOrder = { ...order, id: Date.now().toString(), createTime: new Date().toISOString() }
cached.unshift(newOrder)
uni.setStorageSync('api_orders', cached)
return newOrder
}
},
async updateOrder(order: Order): Promise<boolean> {
try {
const result = await request<{ success: boolean }>(`/orders/${order.id}`, {
method: 'PUT',
data: order
})
return result.success
} catch {
const cached = uni.getStorageSync('api_orders') || []
const index = cached.findIndex(o => o.id === order.id)
if (index > -1) {
cached[index] = order
uni.setStorageSync('api_orders', cached)
}
return true
}
},
async deleteOrder(id: string): Promise<boolean> {
try {
const result = await request<{ success: boolean }>(`/orders/${id}`, {
method: 'DELETE'
})
return result.success
} catch {
const cached = uni.getStorageSync('api_orders') || []
const filtered = cached.filter(o => o.id !== id)
uni.setStorageSync('api_orders', filtered)
return true
}
}
}
export const userApi = {
async getUsers(): Promise<UserItem[]> {
try {
const result = await request<{ data: UserItem[] }>('/users')
return result.data || []
} catch {
const cached = uni.getStorageSync('api_users')
return cached || mockUsers
}
},
async addUser(user: Omit<UserItem, 'id' | 'createTime'>): Promise<boolean> {
try {
const result = await request<{ success: boolean }>('/users', {
method: 'POST',
data: user
})
return result.success
} catch {
const cached = uni.getStorageSync('api_users') || mockUsers
const newUser: UserItem = {
...user,
id: Date.now().toString(),
createTime: new Date().toISOString().split('T')[0]
}
cached.push(newUser)
uni.setStorageSync('api_users', cached)
return true
}
},
async updateUser(user: UserItem): Promise<boolean> {
try {
const result = await request<{ success: boolean }>(`/users/${user.id}`, {
method: 'PUT',
data: user
})
return result.success
} catch {
const cached = uni.getStorageSync('api_users') || mockUsers
const index = cached.findIndex(u => u.id === user.id)
if (index > -1) {
cached[index] = user
uni.setStorageSync('api_users', cached)
}
return true
}
},
async deleteUser(id: string): Promise<boolean> {
try {
const result = await request<{ success: boolean }>(`/users/${id}`, {
method: 'DELETE'
})
return result.success
} catch {
const cached = uni.getStorageSync('api_users') || mockUsers
const filtered = cached.filter(u => u.id !== id)
uni.setStorageSync('api_users', filtered)
return true
}
}
}
export const authApi = {
async login(phone: string, password: string): Promise<{ success: boolean; message: string }> {
try {
const result = await request<{ success: boolean; message: string }>('/admin/login', {
method: 'POST',
data: { phone, password }
})
if (result.success) {
uni.setStorageSync('admin_login', true)
}
return result
} catch {
if (phone === '13800138000' && password === '123456') {
uni.setStorageSync('admin_login', true)
return { success: true, message: '登录成功' }
}
return { success: false, message: '手机号或密码错误' }
}
},
async logout(): Promise<void> {
try {
await request('/auth/logout', { method: 'POST' })
} catch {
console.log('Logout API failed, clearing local storage')
}
uni.removeStorageSync('admin_login')
}
}
export const regionApi = {
async getCities(): Promise<string[]> {
try {
const result = await request<{ data: Array<{id: string; name: string}> }>('/regions/cities')
return result.data?.map(c => c.name) || []
} catch {
return ['天津市', '北京市', '上海市', '广州市']
}
},
async getDistricts(city: string): Promise<string[]> {
try {
const result = await request<{ data: Array<{id: string; cityId: string; name: string; cityName?: string}> }>('/regions/districts')
return result.data?.filter(d => d.cityName === city).map(d => d.name) || []
} catch {
const districtMap: Record<string, string[]> = {
'天津市': ['高新区', '西青区', '保税区'],
'北京市': ['东城区', '西城区', '朝阳区', '海淀区', '丰台区'],
'上海市': ['黄浦区', '徐汇区', '浦东新区', '静安区', '长宁区'],
'广州市': ['天河区', '海珠区', '越秀区', '白云区', '荔湾区']
}
return districtMap[city] || []
}
},
async getStations(city: string, district: string): Promise<string[]> {
try {
const result = await request<{ data: Array<{id: string; cityId: string; districtId: string; name: string; cityName?: string; districtName?: string}> }>('/regions/stations')
return result.data?.filter(s => s.cityName === city && s.districtName === district).map(s => s.name) || []
} catch {
const stationMap: Record<string, Record<string, string[]>> = {
'天津市': {
'高新区': ['金融街融汇景苑', '金辉江山铭筑'],
'西青区': ['华兴里', '华兴南里'],
'保税区': ['意境兰庭']
},
'北京市': {
'东城区': ['景山街道', '交道口街道', '东四街道', '朝阳门街道', '建国门街道'],
'西城区': ['西长安街街道', '新街口街道', '月坛街道', '展览路街道', '德胜街道'],
'朝阳区': ['朝外街道', '劲松街道', '呼家楼街道', '三里屯街道', '团结湖街道'],
'海淀区': ['海淀街道', '中关村街道', '学院路街道', '西三旗街道', '清河街道']
},
'上海市': {
'黄浦区': ['南京东路街道', '外滩街道', '瑞金二路街道', '淮海中路街道', '豫园街道'],
'徐汇区': ['天平路街道', '湖南路街道', '斜土路街道', '枫林路街道', '长桥街道'],
'浦东新区': ['陆家嘴街道', '潍坊新村街道', '塘桥街道', '南码头路街道', '周家渡街道']
},
'广州市': {
'天河区': ['五山街道', '员村街道', '车陂街道', '沙河街道', '石牌街道'],
'海珠区': ['赤岗街道', '新港街道', '昌岗街道', '江南中街道', '滨江街道'],
'越秀区': ['洪桥街道', '北京街道', '六榕街道', '流花街道', '光塔街道']
}
}
return stationMap[city]?.[district] || []
}
},
async getSiteUsers(stationName: string): Promise<string[]> {
try {
const result = await request<{ data: Array<{id: string; stationId: string; name: string; type: string; stationName?: string}> }>('/regions/site-users')
return result.data?.filter(u => u.stationName === stationName).map(u => u.name) || []
} catch {
return []
}
},
async getStationByName(name: string): Promise<{ id: string; name: string } | null> {
try {
const result = await request<{ success: boolean; data?: { id: string; name: string } }>(`/regions/station-by-name?name=${encodeURIComponent(name)}`)
return result.success && result.data ? result.data : null
} catch {
return null
}
},
async addSiteUser(stationId: string, name: string, type: string): Promise<boolean> {
try {
const result = await request<{ success: boolean }>('/regions/site-users', {
method: 'POST',
data: { stationId, name, type }
})
return result.success
} catch {
return false
}
},
async getCommunities(city: string, district: string): Promise<string[]> {
try {
const result = await request<{ data: string[] }>(`/regions/communities?city=${encodeURIComponent(city)}&district=${encodeURIComponent(district)}`)
return result.data || []
} catch {
const communityMap: Record<string, Record<string, string[]>> = {
'天津市': {
'高新区': ['金融街融汇景苑', '金辉江山铭筑'],
'西青区': ['华兴里', '华兴南里'],
'保税区': ['意境兰庭']
},
'北京市': {
'东城区': ['景山街道', '交道口街道', '东四街道', '朝阳门街道', '建国门街道'],
'西城区': ['西长安街街道', '新街口街道', '月坛街道', '展览路街道', '德胜街道'],
'朝阳区': ['朝外街道', '劲松街道', '呼家楼街道', '三里屯街道', '团结湖街道'],
'海淀区': ['海淀街道', '中关村街道', '学院路街道', '西三旗街道', '清河街道']
},
'上海市': {
'黄浦区': ['南京东路街道', '外滩街道', '瑞金二路街道', '淮海中路街道', '豫园街道'],
'徐汇区': ['天平路街道', '湖南路街道', '斜土路街道', '枫林路街道', '长桥街道'],
'浦东新区': ['陆家嘴街道', '潍坊新村街道', '塘桥街道', '南码头路街道', '周家渡街道']
},
'广州市': {
'天河区': ['五山街道', '员村街道', '车陂街道', '沙河街道', '石牌街道'],
'海珠区': ['赤岗街道', '新港街道', '昌岗街道', '江南中街道', '滨江街道'],
'越秀区': ['洪桥街道', '北京街道', '六榕街道', '流花街道', '光塔街道']
}
}
return communityMap[city]?.[district] || []
}
},
async addRegion(city: string, district?: string, community?: string): Promise<boolean> {
try {
const result = await request<{ success: boolean }>('/regions', {
method: 'POST',
data: { city, district, community }
})
return result.success
} catch {
return true
}
}
}
export const statisticsApi = {
async getStatistics(): Promise<any> {
try {
const result = await request<{ success: boolean; data: any }>('/statistics')
return result.data || {}
} catch {
return {
totalOrders: 0,
pendingOrders: 0,
completedOrders: 0,
totalAmount: '0.00',
categoryStats: []
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// 全局配置文件
// 修改此文件中的 API 地址即可适配不同环境
interface Config {
baseUrl: string
}
const config: Record<string, Config> = {
// 开发环境(微信开发者工具)
development: {
baseUrl: '/api'
},
production: {
baseUrl: '/api'
}
}
// 获取当前环境
const env = process.env.NODE_ENV || 'development'
// 导出配置
export const BASE_URL = config[env]?.baseUrl || config.development.baseUrl
export default config
+13
View File
@@ -0,0 +1,13 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return {
app,
pinia
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "易分宝现场回收",
"appid": "__UNI__YIFENBAO",
"description": "易分宝现场回收系统",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"h5": {
"title": "易分宝现场回收",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 5173
}
},
"mp-weixin": {
"appid": "wx0000000000000000",
"setting": {
"urlCheck": false
},
"usingComponents": true
}
}
+136
View File
@@ -0,0 +1,136 @@
{
"pages": [
{
"path": "pages/user/login",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "易分宝回收",
"navigationBarBackgroundColor": "#3366ff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/price/price",
"style": {
"navigationBarTitleText": "今日价格",
"navigationBarBackgroundColor": "#3366ff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/weigh/weigh",
"style": {
"navigationBarTitleText": "称重回收",
"navigationBarBackgroundColor": "#3366ff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/bluetooth/bluetooth",
"style": {
"navigationBarTitleText": "蓝牙连接",
"navigationBarBackgroundColor": "#3366ff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/order/list",
"style": {
"navigationBarTitleText": "订单列表",
"navigationBarBackgroundColor": "#3366ff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/order/detail",
"style": {
"navigationBarTitleText": "订单详情",
"navigationBarBackgroundColor": "#3366ff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/user/profile",
"style": {
"navigationBarTitleText": "个人中心",
"navigationBarBackgroundColor": "#3366ff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/admin/login",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "管理员登录"
}
},
{
"path": "pages/admin/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "管理后台"
}
},
{
"path": "pages/admin/order/list",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "订单管理"
}
},
{
"path": "pages/admin/user/list",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "用户管理"
}
},
{
"path": "pages/admin/statistics",
"style": {
"navigationBarTitleText": "统计报表",
"navigationBarBackgroundColor": "#3366ff",
"navigationBarTextStyle": "white"
}
}
],
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "易分宝现场回收",
"navigationBarBackgroundColor": "#3366ff",
"backgroundColor": "#f5f5f5"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#3366ff",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/icons/home.png",
"selectedIconPath": "static/icons/home-active.png"
},
{
"pagePath": "pages/order/list",
"text": "订单",
"iconPath": "static/icons/order.png",
"selectedIconPath": "static/icons/order-active.png"
}
]
}
}
+408
View File
@@ -0,0 +1,408 @@
<template>
<view class="admin-container">
<view class="admin-header">
<view class="header-left">
<text class="header-title">管理后台</text>
<text class="header-subtitle">易分宝现场回收系统</text>
</view>
<view class="header-right" @click="logout">
<text class="logout-icon">📤</text>
<text class="logout-text">退出</text>
</view>
</view>
<view class="stats-grid">
<view class="stat-card">
<view class="stat-icon">📊</view>
<view class="stat-info">
<text class="stat-value">{{ totalOrders }}</text>
<text class="stat-label">今日订单</text>
</view>
</view>
<view class="stat-card">
<view class="stat-icon">💰</view>
<view class="stat-info">
<text class="stat-value">¥{{ totalAmount.toFixed(2) }}</text>
<text class="stat-label">今日金额</text>
</view>
</view>
<view class="stat-card">
<view class="stat-icon"></view>
<view class="stat-info">
<text class="stat-value">{{ totalWeight.toFixed(2) }}kg</text>
<text class="stat-label">回收总量</text>
</view>
</view>
</view>
<view class="quick-actions">
<view class="section-title">快捷操作</view>
<view class="action-grid">
<view class="action-item" @click="goToOrderList">
<view class="action-icon">📋</view>
<text class="action-name">订单管理</text>
</view>
<view class="action-item" @click="goToUserManager">
<view class="action-icon">👥</view>
<text class="action-name">用户管理</text>
</view>
<view class="action-item" @click="goToStatistics">
<view class="action-icon">📈</view>
<text class="action-name">统计报表</text>
</view>
</view>
</view>
<view class="recent-orders">
<view class="section-title">
<text>最近订单</text>
<text class="view-all" @click="goToOrderList">查看全部</text>
</view>
<view class="order-list">
<view
v-for="order in recentOrders"
:key="order.id"
class="order-item"
@click="goToOrderDetail(order.id)"
>
<view class="order-left">
<view class="order-id">{{ order.id }}</view>
<view class="order-time">{{ order.createTime }}</view>
</view>
<view class="order-right">
<view class="order-amount">¥{{ order.amount.toFixed(2) }}</view>
<view class="order-status" :class="order.status">
{{ getStatusText(order.status) }}
</view>
</view>
</view>
</view>
</view>
<view class="tab-bar">
<view class="tab-item active">
<text class="tab-icon">🏠</text>
<text class="tab-text">首页</text>
</view>
<view class="tab-item" @click="goToOrderList">
<text class="tab-icon">📋</text>
<text class="tab-text">订单</text>
</view>
<view class="tab-item" @click="goToUserManager">
<text class="tab-icon">👤</text>
<text class="tab-text">用户</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useOrderStore } from '@/stores/order'
const orderStore = useOrderStore()
const totalOrders = computed(() => orderStore.orders.length)
const totalAmount = computed(() => {
return orderStore.orders.reduce((sum, order) => sum + order.amount, 0)
})
const totalWeight = computed(() => {
return orderStore.orders.reduce((sum, order) => sum + (order.weight || 0), 0)
})
const recentOrders = computed(() => {
return [...orderStore.orders].reverse().slice(0, 5)
})
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
pending: '待处理',
completed: '已完成',
cancelled: '已取消'
}
return statusMap[status] || status
}
const logout = () => {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.removeStorageSync('admin_login')
uni.redirectTo({ url: '/pages/admin/login' })
}
}
})
}
const goToOrderList = () => {
uni.redirectTo({ url: '/pages/admin/order/list' })
}
const goToUserManager = () => {
uni.redirectTo({ url: '/pages/admin/user/list' })
}
const goToStatistics = () => {
uni.navigateTo({ url: '/pages/admin/statistics' })
}
const goToOrderDetail = (id: string) => {
uni.navigateTo({ url: `/pages/order/detail?id=${id}` })
}
onMounted(() => {
const isLogin = uni.getStorageSync('admin_login')
if (!isLogin) {
uni.redirectTo({ url: '/pages/admin/login' })
return
}
orderStore.loadOrders()
})
</script>
<style lang="scss" scoped>
.admin-container {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 120rpx;
}
.admin-header {
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
padding: 48rpx 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
color: #fff;
}
.header-title {
font-size: 40rpx;
font-weight: 700;
display: block;
}
.header-subtitle {
font-size: 24rpx;
opacity: 0.8;
margin-top: 4rpx;
}
.header-right {
display: flex;
align-items: center;
color: #fff;
padding: 12rpx 24rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 24rpx;
}
.logout-icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.logout-text {
font-size: 26rpx;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 16rpx;
padding: 32rpx;
}
.stat-card {
background: #fff;
border-radius: 12rpx;
padding: 20rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.stat-icon {
font-size: 40rpx;
margin-bottom: 12rpx;
}
.stat-info {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.stat-value {
font-size: 28rpx;
font-weight: 700;
color: #3366ff;
}
.stat-label {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
}
.quick-actions, .recent-orders {
padding: 0 32rpx 32rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.view-all {
font-size: 26rpx;
color: #3366ff;
font-weight: normal;
}
.action-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 24rpx;
}
.action-item {
background: #fff;
border-radius: 16rpx;
padding: 24rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
&:active {
opacity: 0.8;
}
}
.action-icon {
font-size: 48rpx;
margin-bottom: 12rpx;
}
.action-name {
font-size: 24rpx;
color: #333;
text-align: center;
}
.order-list {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.order-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:active {
background: #f9f9f9;
}
}
.order-id {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.order-time {
font-size: 24rpx;
color: #999;
margin-top: 4rpx;
}
.order-amount {
font-size: 32rpx;
font-weight: 700;
color: #3366ff;
}
.order-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
margin-top: 8rpx;
&.pending {
background: #fff3e0;
color: #f59e0b;
}
&.completed {
background: #dcfce7;
color: #3366ff;
}
&.cancelled {
background: #fef2f2;
color: #ef4444;
}
}
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background: #fff;
display: flex;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&.active {
.tab-icon, .tab-text {
color: #3366ff;
}
}
}
.tab-icon {
font-size: 36rpx;
}
.tab-text {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<template>
<view class="login-container">
<view class="login-box">
<view class="login-logo">🔧</view>
<view class="login-title">易分宝管理后台</view>
<view class="login-subtitle">管理员登录</view>
<view class="form-group">
<view class="form-label">手机号</view>
<input
type="text"
v-model="phone"
class="form-input"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<view class="form-group">
<view class="form-label">密码</view>
<input
type="password"
v-model="password"
class="form-input"
placeholder="请输入密码"
/>
</view>
<view class="form-group">
<view class="checkbox" @click="rememberMe = !rememberMe">
<view class="checkbox-box" :class="{ checked: rememberMe }">
<text v-if="rememberMe"></text>
</view>
<text>记住密码</text>
</view>
</view>
<view class="login-btn" @click="login" :class="{ disabled: isLoading }">
<text v-if="isLoading">登录中...</text>
<text v-else>登录</text>
</view>
<view class="forgot-link" @click="forgotPassword">
<text>忘记密码</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { authApi } from '@/api'
const phone = ref('')
const password = ref('')
const rememberMe = ref(false)
const isLoading = ref(false)
const login = async () => {
if (!phone.value) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
return
}
if (phone.value.length !== 11) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
if (!password.value) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
isLoading.value = true
try {
const result = await authApi.login(phone.value, password.value)
if (result.success) {
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.redirectTo({ url: '/pages/admin/index' })
}, 1500)
} else {
uni.showToast({ title: result.message || '手机号或密码错误', icon: 'none' })
}
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
const forgotPassword = () => {
uni.showToast({ title: '请联系管理员重置密码', icon: 'none' })
}
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
}
.login-box {
width: 100%;
max-width: 600rpx;
background: #fff;
border-radius: 24rpx;
padding: 64rpx 48rpx;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
}
.login-logo {
font-size: 96rpx;
text-align: center;
margin-bottom: 24rpx;
}
.login-title {
font-size: 40rpx;
font-weight: 700;
color: #333;
text-align: center;
margin-bottom: 8rpx;
}
.login-subtitle {
font-size: 28rpx;
color: #999;
text-align: center;
margin-bottom: 48rpx;
}
.form-group {
margin-bottom: 32rpx;
}
.form-label {
font-size: 28rpx;
color: #666;
margin-bottom: 12rpx;
}
.form-input {
width: 100%;
height: 88rpx;
border: 2rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 32rpx;
box-sizing: border-box;
&:focus {
border-color: #3366ff;
}
}
.checkbox {
display: flex;
align-items: center;
font-size: 26rpx;
color: #666;
}
.checkbox-box {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #ccc;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
font-size: 24rpx;
color: #fff;
&.checked {
background: #3366ff;
border-color: #3366ff;
}
}
.login-btn {
width: 100%;
height: 88rpx;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-top: 16rpx;
text {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
&:active {
opacity: 0.9;
}
}
.forgot-link {
text-align: center;
margin-top: 32rpx;
text {
font-size: 26rpx;
color: #3366ff;
}
}
</style>
+439
View File
@@ -0,0 +1,439 @@
<template>
<view class="admin-container">
<view class="admin-header">
<view class="header-left">
<text class="header-title">订单管理</text>
<text class="header-subtitle">全部订单</text>
</view>
</view>
<view class="search-bar">
<view class="search-input-wrap">
<text class="search-icon">🔍</text>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="搜索订单号或品类"
/>
</view>
</view>
<view class="filter-bar">
<view
v-for="filter in filters"
:key="filter.value"
class="filter-item"
:class="{ active: currentFilter === filter.value }"
@click="currentFilter = filter.value"
>
{{ filter.label }}
</view>
</view>
<view class="order-list">
<view
v-for="order in filteredOrders"
:key="order.id"
class="order-card"
@click="goToDetail(order.id)"
>
<view class="order-header">
<view class="order-id">订单? {{ order.id }}</view>
<view class="order-status" :class="order.status">
{{ getStatusText(order.status) }}
</view>
</view>
<view class="order-info">
<view class="info-row">
<text class="info-label">品类:</text>
<text class="info-value">{{ order.name }}</text>
</view>
<view class="info-row">
<text class="info-label">重量:</text>
<text class="info-value">{{ order.weight ? order.weight + 'kg' : (order.count ? order.count + '个' : '-') }}</text>
</view>
<view class="info-row">
<text class="info-label">单价:</text>
<text class="info-value">¥{{ order.unitPrice }}/{{ order.unit === 'kg' ? 'kg' : '个' }}</text>
</view>
<view class="info-row">
<text class="info-label">时间:</text>
<text class="info-value">{{ order.createTime }}</text>
</view>
</view>
<view class="order-footer">
<text class="total-amount">¥{{ order.amount.toFixed(2) }}</text>
<view class="order-actions">
<view
v-if="order.status === 'pending'"
class="action-btn confirm"
@click.stop="confirmOrder(order.id)"
>
确认
</view>
<view
v-if="order.status !== 'cancelled'"
class="action-btn cancel"
@click.stop="cancelOrder(order.id)"
>
取消
</view>
</view>
</view>
</view>
</view>
<view class="empty-state" v-if="filteredOrders.length === 0">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无订单</text>
</view>
<view class="tab-bar">
<view class="tab-item" @click="goToHome">
<text class="tab-icon">🏠</text>
<text class="tab-text">首页</text>
</view>
<view class="tab-item active">
<text class="tab-icon">📋</text>
<text class="tab-text">订单</text>
</view>
<view class="tab-item" @click="goToPriceManager">
<text class="tab-icon">💰</text>
<text class="tab-text">价格</text>
</view>
<view class="tab-item" @click="goToUserManager">
<text class="tab-icon">👤</text>
<text class="tab-text">用户</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useOrderStore } from '@/stores/order'
const orderStore = useOrderStore()
const searchKeyword = ref('')
const currentFilter = ref('all')
const filters = [
{ label: '全部', value: 'all' },
{ label: '待处理', value: 'pending' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' }
]
const filteredOrders = computed(() => {
let orders = orderStore.orders
if (currentFilter.value !== 'all') {
orders = orders.filter(order => order.status === currentFilter.value)
}
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
orders = orders.filter(order =>
order.id.toLowerCase().includes(keyword) ||
order.name.toLowerCase().includes(keyword)
)
}
return [...orders].reverse()
})
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
pending: '待处理',
completed: '已完成',
cancelled: '已取消'
}
return statusMap[status] || status
}
const goToDetail = (id: string) => {
uni.navigateTo({ url: `/pages/order/detail?id=${id}` })
}
const confirmOrder = (id: string) => {
uni.showModal({
title: '确认订单',
content: '确定要确认此订单吗?',
success: (res) => {
if (res.confirm) {
orderStore.updateOrderStatus(id, 'completed')
uni.showToast({ title: '已确认', icon: 'success' })
}
}
})
}
const cancelOrder = (id: string) => {
uni.showModal({
title: '取消订单',
content: '确定要取消此订单吗?',
success: (res) => {
if (res.confirm) {
orderStore.updateOrderStatus(id, 'cancelled')
uni.showToast({ title: '已取消', icon: 'success' })
}
}
})
}
const goToHome = () => {
uni.redirectTo({ url: '/pages/admin/index' })
}
const goToPriceManager = () => {
uni.redirectTo({ url: '/pages/admin/price/list' })
}
const goToUserManager = () => {
uni.redirectTo({ url: '/pages/admin/user/list' })
}
onMounted(() => {
const isLogin = uni.getStorageSync('admin_login')
if (!isLogin) {
uni.redirectTo({ url: '/pages/admin/login' })
return
}
orderStore.loadOrders()
})
</script>
<style lang="scss" scoped>
.admin-container {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 120rpx;
}
.admin-header {
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
padding: 48rpx 32rpx;
}
.header-left {
color: #fff;
}
.header-title {
font-size: 40rpx;
font-weight: 700;
display: block;
}
.header-subtitle {
font-size: 24rpx;
opacity: 0.8;
margin-top: 4rpx;
}
.search-bar {
padding: 24rpx 32rpx;
}
.search-input-wrap {
display: flex;
align-items: center;
background: #fff;
border-radius: 40rpx;
padding: 0 24rpx;
height: 72rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.search-icon {
font-size: 28rpx;
margin-right: 16rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
}
.filter-bar {
display: flex;
gap: 16rpx;
padding: 0 32rpx 24rpx;
overflow-x: auto;
}
.filter-item {
padding: 12rpx 28rpx;
background: #fff;
border-radius: 24rpx;
font-size: 26rpx;
color: #666;
white-space: nowrap;
&.active {
background: #3366ff;
color: #fff;
}
}
.order-list {
padding: 0 32rpx;
}
.order-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.order-id {
font-size: 26rpx;
color: #666;
}
.order-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
&.pending {
background: #fff3e0;
color: #f59e0b;
}
&.completed {
background: #dcfce7;
color: #3366ff;
}
&.cancelled {
background: #fef2f2;
color: #ef4444;
}
}
.order-info {
margin-bottom: 20rpx;
}
.info-row {
display: flex;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
}
.info-label {
font-size: 26rpx;
color: #999;
width: 100rpx;
}
.info-value {
font-size: 26rpx;
color: #333;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
}
.total-amount {
font-size: 32rpx;
font-weight: 700;
color: #3366ff;
}
.order-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
padding: 12rpx 28rpx;
border-radius: 24rpx;
font-size: 24rpx;
&.confirm {
background: #3366ff;
color: #fff;
}
&.cancel {
background: #f5f5f5;
color: #666;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background: #fff;
display: flex;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&.active {
.tab-icon, .tab-text {
color: #3366ff;
}
}
}
.tab-icon {
font-size: 36rpx;
}
.tab-text {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
</style>
+496
View File
@@ -0,0 +1,496 @@
<template>
<view class="admin-container">
<view class="admin-header">
<view class="header-left">
<text class="header-title">统计报表</text>
<text class="header-subtitle">数据统计与分析</text>
</view>
</view>
<view class="time-filter">
<view
v-for="filter in timeFilters"
:key="filter.value"
class="filter-item"
:class="{ active: currentFilter === filter.value }"
@click="currentFilter = filter.value"
>
{{ filter.label }}
</view>
</view>
<view class="summary-cards">
<view class="summary-card">
<view class="card-icon">📊</view>
<view class="card-content">
<text class="card-value">{{ summaryData.totalOrders }}</text>
<text class="card-label">总订单数</text>
</view>
</view>
<view class="summary-card">
<view class="card-icon">💰</view>
<view class="card-content">
<text class="card-value">¥{{ summaryData.totalAmount.toFixed(2) }}</text>
<text class="card-label">总金额</text>
</view>
</view>
<view class="summary-card">
<view class="card-icon"></view>
<view class="card-content">
<text class="card-value">{{ summaryData.totalWeight.toFixed(2) }}kg</text>
<text class="card-label">总回收量</text>
</view>
</view>
<view class="summary-card">
<view class="card-icon">📈</view>
<view class="card-content">
<text class="card-value">{{ summaryData.avgAmount.toFixed(2) }}</text>
<text class="card-label">平均单价</text>
</view>
</view>
</view>
<view class="chart-section">
<view class="section-header">
<text class="section-title">品类回收排行</text>
</view>
<view class="chart-list">
<view
v-for="(item, index) in categoryStats"
:key="item.category"
class="chart-item"
>
<view class="chart-rank" :class="getRankClass(index)">
{{ index + 1 }}
</view>
<view class="chart-info">
<view class="chart-name">{{ item.name }}</view>
<view class="chart-bar-wrap">
<view
class="chart-bar"
:style="{ width: getBarWidth(item.amount) + '%' }"
></view>
</view>
</view>
<view class="chart-value">¥{{ item.amount.toFixed(2) }}</view>
</view>
</view>
</view>
<view class="chart-section">
<view class="section-header">
<text class="section-title">每日趋势</text>
</view>
<view class="line-chart">
<view class="chart-grid">
<view class="grid-line" v-for="i in 5" :key="i"></view>
</view>
<view class="chart-bars">
<view
v-for="day in dailyStats"
:key="day.date"
class="bar-item"
>
<view
class="bar"
:style="{ height: getBarHeight(day.amount) + '%' }"
></view>
<text class="bar-label">{{ day.label }}</text>
</view>
</view>
</view>
</view>
<view class="detail-section">
<view class="section-header">
<text class="section-title">订单状态分布</text>
</view>
<view class="status-stats">
<view class="status-item">
<view class="status-circle pending">
<text class="status-value">{{ statusStats.pending }}</text>
</view>
<text class="status-label">待处理</text>
</view>
<view class="status-item">
<view class="status-circle completed">
<text class="status-value">{{ statusStats.completed }}</text>
</view>
<text class="status-label">已完成</text>
</view>
<view class="status-item">
<view class="status-circle cancelled">
<text class="status-value">{{ statusStats.cancelled }}</text>
</view>
<text class="status-label">已取消</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useOrderStore } from '@/stores/order'
const orderStore = useOrderStore()
const currentFilter = ref('today')
const timeFilters = [
{ label: '今日', value: 'today' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '全部', value: 'all' }
]
const summaryData = computed(() => {
const orders = orderStore.orders
const totalOrders = orders.length
const totalAmount = orders.reduce((sum, order) => sum + order.amount, 0)
const totalWeight = orders.reduce((sum, order) => sum + (order.weight || 0), 0)
const avgAmount = totalOrders > 0 ? totalAmount / totalOrders : 0
return {
totalOrders,
totalAmount,
totalWeight,
avgAmount
}
})
const categoryStats = computed(() => {
const categoryMap: Record<string, { name: string; amount: number }> = {}
orderStore.orders.forEach(order => {
if (!categoryMap[order.category]) {
categoryMap[order.category] = { name: order.name, amount: 0 }
}
categoryMap[order.category].amount += order.amount
})
return Object.values(categoryMap).sort((a, b) => b.amount - a.amount).slice(0, 5)
})
const dailyStats = computed(() => {
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return days.map((label, index) => ({
date: `2026-05-${String(index + 1).padStart(2, '0')}`,
label,
amount: Math.floor(Math.random() * 500) + 100
}))
})
const statusStats = computed(() => {
const stats = { pending: 0, completed: 0, cancelled: 0 }
orderStore.orders.forEach(order => {
if (order.status in stats) {
stats[order.status as keyof typeof stats]++
}
})
return stats
})
const getRankClass = (index: number) => {
if (index === 0) return 'rank-1'
if (index === 1) return 'rank-2'
if (index === 2) return 'rank-3'
return ''
}
const getBarWidth = (amount: number) => {
const maxAmount = Math.max(...categoryStats.value.map(item => item.amount), 1)
return (amount / maxAmount) * 100
}
const getBarHeight = (amount: number) => {
const maxAmount = Math.max(...dailyStats.value.map(item => item.amount), 1)
return (amount / maxAmount) * 100
}
onMounted(() => {
const isLogin = uni.getStorageSync('admin_login')
if (!isLogin) {
uni.redirectTo({ url: '/pages/admin/login' })
return
}
orderStore.loadOrders()
})
</script>
<style lang="scss" scoped>
.admin-container {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 32rpx;
}
.admin-header {
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
padding: 48rpx 32rpx;
}
.header-left {
color: #fff;
}
.header-title {
font-size: 40rpx;
font-weight: 700;
display: block;
}
.header-subtitle {
font-size: 24rpx;
opacity: 0.8;
margin-top: 4rpx;
}
.time-filter {
display: flex;
gap: 16rpx;
padding: 24rpx 32rpx;
background: #fff;
}
.filter-item {
padding: 12rpx 28rpx;
background: #f5f5f5;
border-radius: 24rpx;
font-size: 26rpx;
color: #666;
&.active {
background: #3366ff;
color: #fff;
}
}
.summary-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 16rpx;
padding: 32rpx;
}
.summary-card {
background: #fff;
border-radius: 12rpx;
padding: 20rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.card-icon {
font-size: 40rpx;
margin-bottom: 12rpx;
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.card-value {
font-size: 28rpx;
font-weight: 700;
color: #3366ff;
}
.card-label {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
}
.chart-section, .detail-section {
margin: 0 32rpx 32rpx;
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.section-header {
margin-bottom: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.chart-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.chart-item {
display: flex;
align-items: center;
}
.chart-rank {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 600;
color: #666;
margin-right: 16rpx;
&.rank-1 {
background: #ffd700;
color: #fff;
}
&.rank-2 {
background: #c0c0c0;
color: #fff;
}
&.rank-3 {
background: #cd7f32;
color: #fff;
}
}
.chart-info {
flex: 1;
}
.chart-name {
font-size: 26rpx;
color: #333;
margin-bottom: 8rpx;
}
.chart-bar-wrap {
height: 12rpx;
background: #f0f0f0;
border-radius: 6rpx;
overflow: hidden;
}
.chart-bar {
height: 100%;
background: linear-gradient(90deg, #3366ff 0%, #254edb 100%);
border-radius: 6rpx;
transition: width 0.5s ease;
}
.chart-value {
font-size: 26rpx;
font-weight: 600;
color: #3366ff;
margin-left: 16rpx;
}
.line-chart {
position: relative;
height: 300rpx;
}
.chart-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 40rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.grid-line {
height: 1rpx;
background: #f0f0f0;
}
.chart-bars {
position: absolute;
bottom: 40rpx;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
align-items: flex-end;
height: calc(100% - 40rpx);
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
width: 10%;
}
.bar {
width: 80%;
background: linear-gradient(180deg, #3366ff 0%, #254edb 100%);
border-radius: 4rpx 4rpx 0 0;
min-height: 4rpx;
transition: height 0.5s ease;
}
.bar-label {
font-size: 20rpx;
color: #999;
margin-top: 8rpx;
}
.status-stats {
display: flex;
justify-content: space-around;
}
.status-item {
display: flex;
flex-direction: column;
align-items: center;
}
.status-circle {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
&.pending {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
}
&.completed {
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
}
&.cancelled {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
}
}
.status-value {
font-size: 40rpx;
font-weight: 700;
color: #333;
}
.status-label {
font-size: 24rpx;
color: #666;
}
</style>
+556
View File
@@ -0,0 +1,556 @@
<template>
<view class="admin-container">
<view class="admin-header">
<view class="header-left">
<text class="header-title">用户管理</text>
<text class="header-subtitle">系统用户列表</text>
</view>
<view class="header-right" @click="showAddModal = true">
<text class="add-icon">+</text>
<text class="add-text">新增</text>
</view>
</view>
<view class="search-bar">
<view class="search-input-wrap">
<text class="search-icon">🔍</text>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="搜索用户名或手机号"
/>
</view>
</view>
<view class="user-list">
<view
v-for="user in filteredUsers"
:key="user.id"
class="user-card"
>
<view class="user-avatar">
<text class="avatar-icon">{{ user.name.charAt(0) }}</text>
</view>
<view class="user-info">
<view class="user-name">{{ user.name }}</view>
<view class="user-phone">{{ user.phone }}</view>
</view>
<view class="user-role">
<text class="role-tag" :class="user.role">{{ user.role === 'admin' ? '管理员' : '回收员' }}</text>
</view>
<view class="user-actions">
<view class="action-btn edit" @click="editUser(user)"></view>
<view class="action-btn delete" @click="deleteUser(user.id)">🗑</view>
</view>
</view>
</view>
<view class="empty-state" v-if="filteredUsers.length === 0">
<text class="empty-icon">👥</text>
<text class="empty-text">暂无用户</text>
</view>
<view class="tab-bar">
<view class="tab-item" @click="goToHome">
<text class="tab-icon">🏠</text>
<text class="tab-text">首页</text>
</view>
<view class="tab-item" @click="goToOrderList">
<text class="tab-icon">📋</text>
<text class="tab-text">订单</text>
</view>
<view class="tab-item" @click="goToPriceManager">
<text class="tab-icon">💰</text>
<text class="tab-text">价格</text>
</view>
<view class="tab-item active">
<text class="tab-icon">👥</text>
<text class="tab-text">用户</text>
</view>
</view>
<view class="modal-overlay" v-if="showAddModal" @click="closeModal">
<view class="modal-box" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ editingUser ? '编辑用户' : '新增用户' }}</text>
<view class="modal-close" @click="closeModal">×</view>
</view>
<view class="modal-body">
<view class="form-group">
<text class="form-label">用户名</text>
<input
type="text"
v-model="formData.name"
class="form-input"
placeholder="请输入用户名"
/>
</view>
<view class="form-group">
<text class="form-label">手机号</text>
<input
type="number"
v-model="formData.phone"
class="form-input"
placeholder="请输入手机号"
/>
</view>
<view class="form-group">
<text class="form-label">角色</text>
<view class="role-options">
<view
class="role-option"
:class="{ active: formData.role === 'admin' }"
@click="formData.role = 'admin'"
>管理员</view>
<view
class="role-option"
:class="{ active: formData.role === 'collector' }"
@click="formData.role = 'collector'"
>回收员</view>
</view>
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeModal">取消</view>
<view class="modal-btn confirm" @click="saveUser">保存</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const searchKeyword = ref('')
const showAddModal = ref(false)
const editingUser = ref<UserItem | null>(null)
const formData = reactive({
name: '',
phone: '',
role: 'collector'
})
const filteredUsers = computed(() => {
let users = userStore.users
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
users = users.filter(item =>
item.name.toLowerCase().includes(keyword) ||
item.phone.includes(keyword)
)
}
return users
})
const editUser = (user: UserItem) => {
editingUser.value = user
formData.name = user.name
formData.phone = user.phone
formData.role = user.role
showAddModal.value = true
}
const deleteUser = (id: string) => {
uni.showModal({
title: '确认删除',
content: '确定要删除此用户吗?',
success: (res) => {
if (res.confirm) {
userStore.deleteUser(id)
uni.showToast({ title: '删除成功', icon: 'success' })
}
}
})
}
const closeModal = () => {
showAddModal.value = false
editingUser.value = null
formData.name = ''
formData.phone = ''
formData.role = 'collector'
}
const saveUser = async () => {
if (!formData.name) {
uni.showToast({ title: '请输入用户名', icon: 'none' })
return
}
if (!formData.phone || formData.phone.length !== 11) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
if (editingUser.value) {
await userStore.updateUser({
id: editingUser.value.id,
name: formData.name,
phone: formData.phone,
role: formData.role
})
uni.showToast({ title: '修改成功', icon: 'success' })
} else {
await userStore.addUser({
name: formData.name,
phone: formData.phone,
role: formData.role
})
uni.showToast({ title: '新增成功', icon: 'success' })
}
closeModal()
}
const goToHome = () => {
uni.redirectTo({ url: '/pages/admin/index' })
}
const goToOrderList = () => {
uni.redirectTo({ url: '/pages/admin/order/list' })
}
const goToPriceManager = () => {
uni.redirectTo({ url: '/pages/admin/price/list' })
}
onMounted(() => {
userStore.loadUsers()
})
</script>
<style lang="scss" scoped>
.admin-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 120rpx;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 60rpx 30rpx 30rpx;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
.header-left {
.header-title {
font-size: 40rpx;
font-weight: bold;
color: #fff;
display: block;
}
.header-subtitle {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 8rpx;
display: block;
}
}
.header-right {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.2);
padding: 16rpx 32rpx;
border-radius: 40rpx;
.add-icon {
font-size: 32rpx;
color: #fff;
margin-right: 8rpx;
}
.add-text {
font-size: 28rpx;
color: #fff;
}
}
}
.search-bar {
padding: 20rpx 30rpx;
.search-input-wrap {
display: flex;
align-items: center;
background: #fff;
border-radius: 40rpx;
padding: 0 30rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.search-icon {
font-size: 28rpx;
margin-right: 20rpx;
}
.search-input {
flex: 1;
height: 80rpx;
font-size: 28rpx;
}
}
}
.user-list {
padding: 0 30rpx;
}
.user-card {
display: flex;
align-items: center;
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
.avatar-icon {
font-size: 32rpx;
color: #fff;
font-weight: bold;
}
}
.user-info {
flex: 1;
.user-name {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.user-phone {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
}
.user-role {
margin-right: 24rpx;
.role-tag {
font-size: 22rpx;
padding: 8rpx 20rpx;
border-radius: 20rpx;
&.admin {
background: #ffebee;
color: #e53935;
}
&.collector {
background: #e3f2fd;
color: #1976d2;
}
}
}
.user-actions {
display: flex;
.action-btn {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-left: 10rpx;
&.edit {
background: #fff3e0;
}
&.delete {
background: #ffebee;
}
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
.empty-icon {
font-size: 100rpx;
margin-bottom: 20rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
}
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: #fff;
padding: 20rpx 0 60rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.tab-icon {
font-size: 40rpx;
margin-bottom: 8rpx;
}
.tab-text {
font-size: 22rpx;
color: #999;
}
&.active {
.tab-text {
color: #3366ff;
font-weight: bold;
}
}
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.modal-box {
width: 600rpx;
background: #fff;
border-radius: 30rpx;
overflow: hidden;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #eee;
.modal-title {
font-size: 34rpx;
font-weight: bold;
color: #333;
}
.modal-close {
font-size: 40rpx;
color: #999;
padding: 10rpx;
}
}
.modal-body {
padding: 30rpx;
.form-group {
margin-bottom: 30rpx;
.form-label {
font-size: 28rpx;
color: #666;
margin-bottom: 16rpx;
display: block;
}
.form-input {
width: 100%;
height: 80rpx;
border: 2rpx solid #eee;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.role-options {
display: flex;
.role-option {
flex: 1;
height: 80rpx;
border: 2rpx solid #eee;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
margin-right: 20rpx;
&:last-child {
margin-right: 0;
}
&.active {
border-color: #3366ff;
color: #3366ff;
background: rgba(51, 102, 255, 0.1);
}
}
}
}
}
.modal-footer {
display: flex;
border-top: 1rpx solid #eee;
.modal-btn {
flex: 1;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
&.cancel {
color: #666;
border-right: 1rpx solid #eee;
}
&.confirm {
color: #3366ff;
font-weight: bold;
}
}
}
}
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<template>
<view class="container">
<view class="scan-header">
<view class="scan-btn" :class="{ scanning: bluetoothStore.isDiscovering }" @click="toggleScan">
<image class="scan-icon-img" :src="bluetoothStore.isDiscovering ? '/static/icons/remind.png' : '/static/icons/search.png'" mode="aspectFit" />
<text>{{ bluetoothStore.isDiscovering ? '停止搜索' : '搜索设备' }}</text>
</view>
</view>
<view class="status-section">
<view class="status-item connected" v-if="bluetoothStore.isConnected">
<text class="status-dot"></text>
<text class="status-text">已连接接 {{ bluetoothStore.connectedDevice?.name }}</text>
<view class="disconnect-btn" @click="disconnect">断开</view>
</view>
<view class="status-item disconnected" v-else>
<text class="status-dot"></text>
<text class="status-text">未连接接设备</text>
</view>
</view>
<view class="device-list" v-if="bluetoothStore.deviceList.length > 0">
<view class="list-title">
<text class="list-icon">📱</text>
<text>可用设备</text>
</view>
<view
v-for="device in bluetoothStore.deviceList"
:key="device.deviceId"
class="device-item"
@click="connect(device.deviceId)"
>
<view class="device-info">
<text class="device-icon">📦</text>
<view class="device-detail">
<text class="device-name">{{ device.name }}</text>
<text class="device-id">{{ device.deviceId }}</text>
</view>
</view>
<view class="device-signal">
<text>{{ getSignalStrength(device.RSSI) }}</text>
</view>
</view>
</view>
<view class="empty-state" v-else>
<image class="empty-icon-img" src="/static/icons/empty-device.png" mode="aspectFit" />
<text class="empty-text">暂无可用设备</text>
<text class="empty-hint">请确保电子秤已开启蓝牙</text>
</view>
<view class="tips-card">
<image class="tips-icon-img" src="/static/icons/remind.png" mode="aspectFit" />
<text class="tips-text">搜索范围10米设备名称通常包含"Scale"</text>
</view>
</view>
</template>
<script setup lang="ts">
import { useBluetoothStore } from '@/stores/bluetooth'
const bluetoothStore = useBluetoothStore()
const toggleScan = () => {
if (bluetoothStore.isDiscovering) {
bluetoothStore.stopScan()
} else {
bluetoothStore.startScan()
}
}
const connect = async (deviceId: string) => {
if (bluetoothStore.isConnected) {
await bluetoothStore.disconnect()
}
await bluetoothStore.connect(deviceId)
if (bluetoothStore.isConnected) {
uni.showToast({ title: '连接成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
}
const disconnect = async () => {
await bluetoothStore.disconnect()
uni.showToast({ title: '已断开连接', icon: 'none' })
}
const getSignalStrength = (rssi: number): string => {
if (rssi >= -50) return '📶'
if (rssi >= -70) return '📡'
return '📴'
}
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
padding: 32rpx;
}
.scan-header {
margin-bottom: 24rpx;
}
.scan-btn {
background: #fff;
border-radius: 40rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 600;
color: #3366ff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
&.scanning {
background: #fee2e2;
color: #ef4444;
}
}
.scan-icon {
margin-right: 12rpx;
}
.scan-icon-img {
width: 40rpx;
height: 40rpx;
margin-right: 12rpx;
}
.status-section {
margin-bottom: 32rpx;
}
.status-item {
display: flex;
align-items: center;
padding: 20rpx 24rpx;
border-radius: 12rpx;
&.connected {
background: #dcfce7;
}
&.disconnected {
background: #fef2f2;
}
}
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-right: 12rpx;
.connected & {
background: #22c55e;
}
.disconnected & {
background: #ef4444;
}
}
.status-text {
flex: 1;
font-size: 28rpx;
.connected & {
color: #166534;
}
.disconnected & {
color: #991b1b;
}
}
.disconnect-btn {
font-size: 26rpx;
color: #ef4444;
padding: 8rpx 20rpx;
background: rgba(239, 68, 68, 0.1);
border-radius: 8rpx;
}
.device-list {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
margin-bottom: 24rpx;
}
.list-title {
display: flex;
align-items: center;
padding: 24rpx;
font-size: 30rpx;
font-weight: 600;
color: #333;
border-bottom: 1rpx solid #f0f0f0;
}
.list-icon {
margin-right: 12rpx;
}
.device-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:active {
background: #f9fafb;
}
}
.device-info {
display: flex;
align-items: center;
flex: 1;
}
.device-icon {
font-size: 40rpx;
margin-right: 16rpx;
}
.device-detail {
display: flex;
flex-direction: column;
}
.device-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 4rpx;
}
.device-id {
font-size: 22rpx;
color: #999;
}
.device-signal {
font-size: 26rpx;
color: #666;
padding: 6rpx 16rpx;
background: #f3f4f6;
border-radius: 20rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 40rpx;
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-icon-img {
width: 100rpx;
height: 100rpx;
margin-bottom: 20rpx;
}
.empty-text {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.empty-hint {
font-size: 26rpx;
color: #999;
}
.tips-card {
background: #eff6ff;
border: 1rpx solid #dbeafe;
border-radius: 12rpx;
padding: 20rpx 24rpx;
display: flex;
align-items: center;
}
.tips-icon {
font-size: 32rpx;
margin-right: 12rpx;
}
.tips-icon-img {
width: 36rpx;
height: 36rpx;
margin-right: 12rpx;
}
.tips-text {
font-size: 26rpx;
color: #1e40af;
}
</style>
+592
View File
@@ -0,0 +1,592 @@
<template>
<view class="container">
<view class="content">
<view class="region-card">
<view class="region-title">
<image class="region-icon" src="/static/Location.png" mode="aspectFit" />
<text>选择区域</text>
</view>
<view class="region-row">
<view class="region-item">
<text class="region-label">城市</text>
<view class="region-select" @click="showCityPicker = true">
<text>{{ regionStore.selectedCity || '请选择城市' }}</text>
<text class="arrow"></text>
</view>
</view>
</view>
<view class="region-row">
<view class="region-item">
<text class="region-label"></text>
<view class="region-select" @click="showDistrictPicker = true" :class="{ disabled: !regionStore.selectedCity }">
<text>{{ regionStore.selectedDistrict || '请选择区' }}</text>
<text class="arrow"></text>
</view>
</view>
</view>
<view class="custom-region" v-if="regionStore.selectedDistrict === '其他'">
<input
type="text"
:value="regionStore.customDistrict"
@input="onCustomDistrictInput"
placeholder="请输入区名称"
class="region-input"
/>
</view>
<view class="region-row">
<view class="region-item">
<text class="region-label">回收站点</text>
<view class="region-select" @click="showCommunityPicker = true" :class="{ disabled: !regionStore.selectedDistrict }">
<text>{{ regionStore.selectedCommunity || '请选择回收站点' }}</text>
<text class="arrow"></text>
</view>
</view>
</view>
<view class="region-row">
<view class="region-item">
<text class="region-label">用户</text>
<view class="region-select" @click="showUserPicker = true" :class="{ disabled: !regionStore.selectedCommunity }">
<text>{{ regionStore.selectedUser || '请选择用户' }}</text>
<text class="arrow"></text>
</view>
</view>
</view>
<view class="custom-region" v-if="regionStore.selectedUser === '新商户'">
<input
type="text"
:value="regionStore.customUser"
@input="onCustomUserInput"
placeholder="自定义请输入商户名称"
class="region-input"
/>
<view class="custom-confirm-btn" @click="confirmCustomUser">确认添加</view>
</view>
<view class="custom-region" v-if="regionStore.selectedUser === '新个人'">
<input
type="text"
:value="regionStore.customUser"
@input="onCustomUserInput"
placeholder="自定义请输入个人信息"
class="region-input"
/>
<view class="custom-confirm-btn" @click="confirmCustomUser">确认添加</view>
</view>
</view>
<view class="section-title">
<image class="section-icon" src="/static/Recycle.png" mode="aspectFit" />
<text>选择回收种类</text>
</view>
<view class="category-grid">
<view
v-for="item in priceStore.prices"
:key="item.category"
class="category-item"
@click="goToWeigh(item.category)"
>
<view class="category-icon-wrap">
<image class="category-icon-img" :src="getCategoryIcon(item.icon)" mode="aspectFit" />
</view>
<view class="category-name">{{ item.name }}</view>
</view>
</view>
</view>
<view class="picker-overlay" v-if="showCityPicker" @click="closePickers">
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-title">选择城市</text>
<text class="picker-close" @click="closePickers"></text>
</view>
<scroll-view scroll-y class="picker-list">
<view
v-for="city in cities"
:key="city"
class="picker-item"
:class="{ active: regionStore.selectedCity === city }"
@click="selectCity(city)"
>
<text>{{ city }}</text>
</view>
</scroll-view>
</view>
</view>
<view class="picker-overlay" v-if="showDistrictPicker" @click="closePickers">
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-title">选择区</text>
<text class="picker-close" @click="closePickers"></text>
</view>
<scroll-view scroll-y class="picker-list">
<view
v-for="district in districts"
:key="district"
class="picker-item"
:class="{ active: regionStore.selectedDistrict === district }"
@click="selectDistrict(district)"
>
<text>{{ district }}</text>
</view>
</scroll-view>
</view>
</view>
<view class="picker-overlay" v-if="showCommunityPicker" @click="closePickers">
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-title">选择回收站点</text>
<text class="picker-close" @click="closePickers"></text>
</view>
<view class="picker-search">
<input
type="text"
v-model="communitySearchText"
placeholder="搜索回收站点"
class="picker-search-input"
/>
</view>
<scroll-view scroll-y class="picker-list">
<view
v-for="community in filteredCommunities"
:key="community"
class="picker-item"
:class="{ active: regionStore.selectedCommunity === community }"
@click="selectCommunity(community)"
>
<text>{{ community }}</text>
</view>
</scroll-view>
</view>
</view>
<view class="picker-overlay" v-if="showUserPicker" @click="closePickers">
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-title">选择用户</text>
<text class="picker-close" @click="closePickers"></text>
</view>
<view class="picker-search">
<input
type="text"
v-model="userSearchText"
placeholder="搜索用户"
class="picker-search-input"
/>
</view>
<scroll-view scroll-y class="picker-list">
<view
v-for="user in filteredUsers"
:key="user"
class="picker-item"
:class="{ active: regionStore.selectedUser === user }"
@click="selectUser(user)"
>
<text>{{ user }}</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { usePriceStore } from '@/stores/price'
import { useRegionStore } from '@/stores/region'
import { getCategoryIcon } from '@/utils/price'
import { regionApi } from '@/api'
const priceStore = usePriceStore()
const regionStore = useRegionStore()
const cities = ref<string[]>([])
const districts = ref<string[]>([])
const communities = ref<string[]>([])
const users = ref<string[]>([])
const showCityPicker = ref(false)
const showDistrictPicker = ref(false)
const showCommunityPicker = ref(false)
const showUserPicker = ref(false)
const communitySearchText = ref('')
const userSearchText = ref('')
const filteredCommunities = computed(() => {
if (!communitySearchText.value) return communities.value
const keyword = communitySearchText.value.toLowerCase()
return communities.value.filter(c => c.toLowerCase().includes(keyword))
})
const filteredUsers = computed(() => {
if (!userSearchText.value) return users.value
const keyword = userSearchText.value.toLowerCase()
return users.value.filter(u => u.toLowerCase().includes(keyword))
})
const goToWeigh = (category: string) => {
uni.navigateTo({ url: `/pages/weigh/weigh?category=${category}` })
}
const loadCities = async () => {
cities.value = await regionApi.getCities()
}
const loadDistricts = async (city: string) => {
const result = await regionApi.getDistricts(city)
districts.value = [...result, '其他']
}
const loadCommunities = async (city: string, district: string) => {
const result = await regionApi.getStations(city, district)
communities.value = [...result]
}
const selectCity = async (city: string) => {
regionStore.setCity(city)
showCityPicker.value = false
await loadDistricts(city)
}
const selectDistrict = async (district: string) => {
regionStore.setDistrict(district)
showDistrictPicker.value = false
await loadCommunities(regionStore.selectedCity, district)
}
const loadUsers = async () => {
const stationName = regionStore.selectedCommunity
if (stationName) {
const result = await regionApi.getSiteUsers(stationName)
const filtered = result.filter(u => u !== '新商户' && u !== '新个人')
users.value = ['匿名用户', ...filtered, '新商户', '新个人']
} else {
users.value = ['匿名用户', '新商户', '新个人']
}
}
const selectCommunity = async (community: string) => {
regionStore.setCommunity(community)
showCommunityPicker.value = false
await loadUsers()
}
const selectUser = (user: string) => {
regionStore.setUser(user)
showUserPicker.value = false
}
const confirmCustomUser = async () => {
const user = regionStore.selectedUser
if (user !== '新商户' && user !== '新个人') return
if (!regionStore.customUser.trim()) {
uni.showToast({ title: '请输入自定义内容', icon: 'none' })
return
}
try {
const stationResult = await regionApi.getStationByName(regionStore.selectedCommunity)
if (stationResult && stationResult.id) {
const type = user === '新商户' ? 'merchant' : 'individual'
await regionApi.addSiteUser(stationResult.id, regionStore.customUser.trim(), type)
uni.showToast({ title: '保存成功', icon: 'success' })
regionStore.setUser(regionStore.customUser.trim())
regionStore.setCustomUser('')
await loadUsers()
} else {
uni.showToast({ title: '站点信息获取失败', icon: 'none' })
}
} catch (error) {
console.error('保存用户失败:', error)
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
const onCustomDistrictInput = (e: any) => {
regionStore.setCustomDistrict(e.detail.value)
}
const onCustomCommunityInput = (e: any) => {
regionStore.setCustomCommunity(e.detail.value)
}
const onCustomUserInput = (e: any) => {
regionStore.setCustomUser(e.detail.value)
}
const closePickers = () => {
showCityPicker.value = false
showDistrictPicker.value = false
showCommunityPicker.value = false
showUserPicker.value = false
}
onMounted(async () => {
const isLoggedIn = uni.getStorageSync('collector_login')
if (!isLoggedIn) {
uni.redirectTo({ url: '/pages/user/login' })
return
}
priceStore.loadPrices()
await loadCities()
if (regionStore.selectedCity) {
await loadDistricts(regionStore.selectedCity)
}
})
onShow(() => {
priceStore.loadPrices()
})
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
}
.content {
padding: 32rpx;
padding-top: 48rpx;
padding-bottom: 180rpx;
}
.region-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.region-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
display: flex;
align-items: center;
}
.region-icon {
width: 36rpx;
height: 36rpx;
margin-right: 12rpx;
}
.region-row {
margin-bottom: 16rpx;
}
.region-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.region-label {
font-size: 28rpx;
color: #666;
width: 120rpx;
}
.region-select {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
font-size: 28rpx;
color: #333;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
.arrow {
font-size: 20rpx;
color: #999;
}
}
.custom-region {
padding-left: 120rpx;
margin-bottom: 16rpx;
.region-input {
width: calc(100% - 20rpx);
height: 80rpx;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
margin-left: 10rpx;
line-height: 40rpx;
}
}
.custom-confirm-btn {
margin-left: 10rpx;
margin-top: 16rpx;
width: calc(100% - 20rpx);
height: 80rpx;
line-height: 80rpx;
text-align: center;
background: #3366ff;
color: #fff;
font-size: 30rpx;
border-radius: 8rpx;
}
.picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.picker-content {
width: 100%;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
max-height: 60vh;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.picker-search {
padding: 20rpx 32rpx;
background: #f8f8f8;
}
.picker-search-input {
width: 100%;
height: 72rpx;
padding: 0 24rpx;
background: #fff;
border-radius: 36rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.picker-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.picker-close {
font-size: 36rpx;
color: #999;
}
.picker-list {
max-height: 50vh;
}
.picker-item {
padding: 28rpx 32rpx;
font-size: 30rpx;
color: #333;
border-bottom: 1rpx solid #f5f5f5;
&.active {
color: #3366ff;
background: #f0f5ff;
}
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 24rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.section-icon {
width: 36rpx;
height: 36rpx;
margin-right: 12rpx;
}
.category-grid {
display: flex;
flex-wrap: wrap;
gap: 24rpx;
justify-content: space-between;
box-sizing: border-box;
width: 100%;
}
.category-item {
width: 47%;
background: #fff;
border-radius: 16rpx;
padding: 32rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.2s;
box-sizing: border-box;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
}
.category-icon {
margin-bottom: 16rpx;
}
.category-icon-wrap {
width: 64rpx;
height: 64rpx;
margin-bottom: 16rpx;
}
.category-icon-img {
width: 100%;
height: 100%;
}
.category-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
</style>
+318
View File
@@ -0,0 +1,318 @@
<template>
<view class="container">
<view class="detail-card">
<view class="detail-header">
<view class="order-info">
<text class="order-id">{{ order?.orderId }}</text>
<text class="order-time">{{ order ? formatOrderTime(order.timestamp) : '-' }}</text>
</view>
<view class="order-status" :class="order?.status">
{{ order?.status === 'completed' ? '已完成' : order?.status === 'pending' ? '待处理' : '已取消' }}
</view>
</view>
<view class="detail-section">
<view class="section-title">
<text class="section-icon">📦</text>
<text>回收物品</text>
</view>
<view class="items-list">
<view
v-for="(item, index) in order?.items"
:key="index"
class="item-card"
>
<view class="item-left">
<image class="item-icon-img" :src="getCategoryIcon(item.icon)" mode="aspectFit" />
<view class="item-info">
<text class="item-name">{{ item.name }}</text>
<text class="item-spec">
{{ item.unit === 'kg' ? '重量' : '数量' }}: {{ item.weight || item.count }} {{ item.unit === 'kg' ? 'kg' : '个' }}
</text>
</view>
</view>
<view class="item-right">
<text class="item-price">¥{{ item.unitPrice }}/{{ item.unit === 'kg' ? 'kg' : '个' }}</text>
<text class="item-amount">¥{{ item.amount.toFixed(2) }}</text>
</view>
</view>
</view>
</view>
<view class="detail-section">
<view class="section-title">
<text class="section-icon">💰</text>
<text>金额明细</text>
</view>
<view class="amount-detail">
<view class="amount-row">
<text class="amount-label">物品总价</text>
<text class="amount-value">¥{{ order?.totalAmount.toFixed(2) || '0.00' }}</text>
</view>
<view class="amount-row">
<text class="amount-label">优惠金额</text>
<text class="amount-value discount">-¥0.00</text>
</view>
<view class="amount-row total">
<text class="amount-label">实付金额</text>
<text class="amount-value">¥{{ order?.totalAmount.toFixed(2) || '0.00' }}</text>
</view>
</view>
</view>
</view>
<view class="bottom-actions">
<view class="action-btn secondary" @click="goBack">
<text>返回</text>
</view>
<view class="action-btn primary" @click="createNewOrder">
<text>继续回收</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { Order } from '@/utils/order'
import { formatOrderTime } from '@/utils/order'
import { getCategoryIcon } from '@/utils/price'
const order = ref<Order | null>(null)
const goBack = () => {
uni.navigateBack()
}
const createNewOrder = () => {
uni.switchTab({ url: '/pages/index/index' })
}
onLoad(async (options) => {
if (options?.orderId) {
const data = uni.getStorageSync('orders')
const orders = data ? JSON.parse(data) : []
order.value = orders.find((o: Order) => o.orderId === options.orderId) || null
}
})
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
padding: 32rpx;
padding-bottom: 160rpx;
}
.detail-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 28rpx 24rpx;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
}
.order-info {
display: flex;
flex-direction: column;
}
.order-id {
font-size: 30rpx;
font-weight: 600;
color: #fff;
margin-bottom: 8rpx;
}
.order-time {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.order-status {
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.detail-section {
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.section-title {
display: flex;
align-items: center;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.section-icon {
margin-right: 12rpx;
}
.items-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.item-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #f9fafb;
border-radius: 12rpx;
}
.item-left {
display: flex;
align-items: center;
flex: 1;
}
.item-icon {
font-size: 44rpx;
margin-right: 16rpx;
}
.item-icon-img {
width: 48rpx;
height: 48rpx;
margin-right: 16rpx;
}
.item-info {
display: flex;
flex-direction: column;
}
.item-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 4rpx;
}
.item-spec {
font-size: 24rpx;
color: #999;
}
.item-right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.item-price {
font-size: 24rpx;
color: #999;
margin-bottom: 4rpx;
}
.item-amount {
font-size: 32rpx;
font-weight: 600;
color: #3366ff;
}
.amount-detail {
background: #f9fafb;
border-radius: 12rpx;
padding: 20rpx;
}
.amount-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
&.total {
padding-top: 16rpx;
margin-top: 12rpx;
border-top: 1rpx dashed #e5e7eb;
}
}
.amount-label {
font-size: 28rpx;
color: #666;
.total & {
font-weight: 600;
color: #333;
}
}
.amount-value {
font-size: 28rpx;
font-weight: 600;
color: #333;
&.discount {
color: #f59e0b;
}
.total & {
font-size: 36rpx;
color: #3366ff;
}
}
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.action-btn {
flex: 1;
border-radius: 40rpx;
padding: 24rpx;
text-align: center;
font-size: 32rpx;
font-weight: 600;
&.secondary {
background: #f3f4f6;
color: #666;
}
&.primary {
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
color: #fff;
}
&:active {
opacity: 0.85;
}
}
</style>
+483
View File
@@ -0,0 +1,483 @@
<template>
<view class="container" :style="{ paddingBottom: containerPaddingBottom }">
<view class="content">
<view class="order-list" v-if="todayOrders.length > 0">
<view
v-for="order in todayOrders"
:key="order.id"
class="order-item"
>
<view class="order-checkbox" @click.stop="toggleSelect(order.id)">
<view class="checkbox" :class="{ checked: selectedOrders.includes(order.id) }">
<text v-if="selectedOrders.includes(order.id)" class="check-icon"></text>
</view>
</view>
<view class="order-content">
<view class="order-header">
<view class="order-region">
<text class="region-icon">📍</text>
<text class="region-text">{{ getRegionText(order) }}</text>
</view>
<text class="order-time">{{ formatOrderTime(order.createTime) }}</text>
</view>
<view class="order-items">
<view
v-for="(item, index) in order.items"
:key="index"
class="order-item-row"
>
<text class="item-name">{{ item.name }}</text>
<view class="item-info">
<text class="item-weight">{{ item.weight || item.count }} {{ item.unit === 'kg' ? 'kg' : '个' }}</text>
<text class="item-price">¥{{ item.price.toFixed(2) }}/{{ item.unit === 'kg' ? 'kg' : '个' }}</text>
</view>
<text class="item-amount">¥{{ (item.amount || 0).toFixed(2) }}</text>
</view>
</view>
<view class="order-footer">
<view class="order-total">
<text class="total-label">合计:</text>
<text class="total-amount">¥{{ (order.totalAmount || 0).toFixed(2) }}</text>
</view>
<view class="order-actions">
<text class="delete-btn" @click.stop="handleDelete(order.id)">删除</text>
</view>
</view>
</view>
</view>
</view>
<view class="empty-state" v-else>
<image class="empty-icon" src="/static/no-orders.png" mode="aspectFit"></image>
<text class="empty-text">暂无今日订单</text>
<text class="empty-hint">今日完成回收后将在此显示订单记录</text>
</view>
</view>
<view class="bottom-bar" v-if="todayOrders.length > 0" :style="{ bottom: bottomBarBottom }">
<view class="select-all" @click="toggleSelectAll">
<view class="checkbox" :class="{ checked: isAllSelected }">
<text v-if="isAllSelected" class="check-icon"></text>
</view>
<text class="select-text">全选</text>
</view>
<view class="selected-info">
<text class="selected-count">已选 {{ selectedOrders.length }} </text>
<text class="selected-total">合计: ¥{{ selectedTotalAmount.toFixed(2) }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useOrderStore } from '@/stores/order'
import { formatOrderTime } from '@/utils/order'
const orderStore = useOrderStore()
const selectedOrders = ref<string[]>([])
const tabBarHeight = ref(50)
const safeAreaBottom = ref(0)
const todayOrders = computed(() => {
const today = new Date().toISOString().split('T')[0]
return orderStore.orders.filter(order => {
const orderDate = order.createTime?.split(' ')[0] || order.createTime?.split('T')[0]
return orderDate === today
})
})
const isAllSelected = computed(() => {
return todayOrders.value.length > 0 && selectedOrders.value.length === todayOrders.value.length
})
const selectedTotalAmount = computed(() => {
return selectedOrders.value.reduce((sum, orderId) => {
const order = orderStore.orders.find(o => o.id === orderId)
return sum + (order?.totalAmount || 0)
}, 0)
})
const bottomBarBottom = computed(() => {
return `${tabBarHeight.value + safeAreaBottom.value}px`
})
const containerPaddingBottom = computed(() => {
return `${tabBarHeight.value + 60 + safeAreaBottom.value}px`
})
const toggleSelect = (orderId: string) => {
const index = selectedOrders.value.indexOf(orderId)
if (index > -1) {
selectedOrders.value.splice(index, 1)
} else {
selectedOrders.value.push(orderId)
}
}
const toggleSelectAll = () => {
if (isAllSelected.value) {
selectedOrders.value = []
} else {
selectedOrders.value = todayOrders.value.map(o => o.id)
}
}
const handleDelete = (orderId: string) => {
uni.showModal({
title: '确认删除',
content: '确定要删除这笔订单吗?',
success: async (res) => {
if (res.confirm) {
await orderStore.deleteOrder(orderId)
selectedOrders.value = selectedOrders.value.filter(id => id !== orderId)
}
}
})
}
const handleBatchDelete = () => {
uni.showModal({
title: '批量删除',
content: `确定要删除选中的 ${selectedOrders.value.length} 笔订单吗?`,
success: async (res) => {
if (res.confirm) {
for (const orderId of selectedOrders.value) {
await orderStore.deleteOrder(orderId)
}
selectedOrders.value = []
}
}
})
}
const getRegionText = (order: typeof orderStore.orders[0]) => {
const parts = []
if (order.city) parts.push(order.city)
if (order.district) parts.push(order.district)
if (order.community) parts.push(order.community)
if (order.user) parts.push(order.user)
return parts.join(' > ') || '未选择区域'
}
const getTabBarHeight = () => {
if (typeof window !== 'undefined') {
const selectors = [
'.uni-tabbar',
'.uni-tabbar-bottom',
'.uni-tabbar__wrapper',
'[class*="tabbar"]',
'.tab-bar',
'.tabbar'
]
let foundTabBar = null
for (const selector of selectors) {
const el = document.querySelector(selector)
if (el) {
foundTabBar = el
break
}
}
if (foundTabBar) {
tabBarHeight.value = (foundTabBar as HTMLElement).offsetHeight
} else {
const allBottomElements = document.querySelectorAll('div')
for (const el of allBottomElements) {
const rect = (el as HTMLElement).getBoundingClientRect()
if (rect.bottom >= window.innerHeight - 100 && rect.top >= window.innerHeight - 150) {
tabBarHeight.value = rect.height
break
}
}
}
if (tabBarHeight.value < 40) {
tabBarHeight.value = 60
}
let safeArea = 0
const style = window.getComputedStyle(document.documentElement)
safeArea = parseInt(style.getPropertyValue('--safe-area-inset-bottom')) || 0
if (safeArea === 0) {
safeArea = parseInt(style.getPropertyValue('safe-area-inset-bottom')) || 0
}
if (safeArea === 0) {
try {
safeArea = parseInt(window.getComputedStyle(document.body).getPropertyValue('--safe-area-inset-bottom')) || 0
} catch (e) {}
}
safeAreaBottom.value = safeArea || 0
const viewportMeta = document.querySelector('meta[name="viewport"]')
if (viewportMeta && !viewportMeta.getAttribute('content')?.includes('viewport-fit')) {
viewportMeta.setAttribute('content', viewportMeta.getAttribute('content') + ', viewport-fit=cover')
}
}
}
onMounted(() => {
orderStore.loadOrders()
setTimeout(() => {
getTabBarHeight()
}, 500)
setTimeout(() => {
getTabBarHeight()
}, 1500)
})
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
}
.content {
padding: 32rpx;
}
.order-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.order-item {
display: flex;
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.order-checkbox {
display: flex;
align-items: flex-start;
padding-right: 20rpx;
padding-top: 8rpx;
}
.checkbox {
width: 44rpx;
height: 44rpx;
border: 2rpx solid #ddd;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&.checked {
background: #3366ff;
border-color: #3366ff;
}
}
.check-icon {
color: #fff;
font-size: 24rpx;
font-weight: bold;
}
.order-content {
flex: 1;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx dashed #f0f0f0;
}
.order-region {
display: flex;
align-items: center;
flex: 1;
}
.region-icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.region-text {
font-size: 26rpx;
color: #333;
font-weight: 500;
}
.order-time {
font-size: 24rpx;
color: #999;
}
.order-items {
margin-bottom: 16rpx;
}
.order-item-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
&:not(:last-child) {
border-bottom: 1rpx solid #f8f9fa;
}
}
.item-name {
font-size: 28rpx;
color: #333;
flex: 1;
}
.item-info {
display: flex;
flex-direction: column;
align-items: flex-end;
flex: 2;
margin-right: 16rpx;
}
.item-weight {
font-size: 24rpx;
color: #666;
}
.item-price {
font-size: 24rpx;
color: #999;
}
.item-amount {
font-size: 28rpx;
font-weight: 600;
color: #3366ff;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16rpx;
border-top: 1rpx dashed #f0f0f0;
}
.order-total {
display: flex;
align-items: baseline;
}
.total-label {
font-size: 26rpx;
color: #666;
margin-right: 8rpx;
}
.total-amount {
font-size: 36rpx;
font-weight: 700;
color: #3366ff;
}
.order-actions {
margin-left: 16rpx;
}
.delete-btn {
font-size: 26rpx;
color: #ff4d4f;
padding: 8rpx 16rpx;
border: 1rpx solid #ff4d4f;
border-radius: 8rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 40rpx;
background: #fff;
border-radius: 16rpx;
}
.empty-icon {
width: 120rpx;
height: 120rpx;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
}
.empty-hint {
font-size: 26rpx;
color: #999;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #fff;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
z-index: 100;
}
.select-all {
display: flex;
align-items: center;
}
.select-text {
font-size: 28rpx;
color: #333;
margin-left: 12rpx;
}
.selected-info {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.selected-count {
font-size: 26rpx;
color: #666;
}
.selected-total {
font-size: 32rpx;
font-weight: 700;
color: #3366ff;
}
.batch-delete {
margin-left: 16rpx;
}
.batch-delete-btn {
font-size: 28rpx;
color: #fff;
padding: 12rpx 24rpx;
background: #ff4d4f;
border-radius: 8rpx;
}
</style>
+223
View File
@@ -0,0 +1,223 @@
<template>
<view class="container">
<view class="date-header">
<view class="date-label-wrap">
<image class="date-icon" src="/static/Price.png" mode="aspectFit" />
<text class="date-label">今日价格</text>
</view>
<text class="date-value">{{ priceStore.date }}</text>
</view>
<view class="refresh-btn" @click="refreshPrices">
<text class="refresh-icon">🔄</text>
<text>刷新</text>
</view>
<view class="price-list">
<view
v-for="item in priceStore.prices"
:key="item.category"
class="price-item"
>
<view class="price-item-left">
<view class="price-icon-wrap">
<image class="price-icon-img" :src="getCategoryIcon(item.icon)" mode="aspectFit" />
</view>
<view class="price-info">
<text class="price-name">{{ item.name }}</text>
<text class="price-desc">{{ item.unit === 'kg' ? '重量' : '个数' }}计价</text>
</view>
</view>
<view class="price-item-right">
<text class="price-amount">
<text class="currency">¥</text>
<text class="amount-num">{{ item.price }}</text>
<text class="amount-unit">/{{ item.unit === 'kg' ? 'kg' : '个' }}</text>
</text>
</view>
</view>
</view>
<view class="tips-card">
<text class="tips-icon">💡</text>
<text class="tips-text">价格每日更新实际回收以当日价格为准</text>
</view>
</view>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { usePriceStore } from '@/stores/price'
import { getCategoryIcon } from '@/utils/price'
const priceStore = usePriceStore()
const refreshPrices = () => {
priceStore.loadPrices()
uni.showToast({ title: '已刷新', icon: 'success' })
}
onMounted(() => {
priceStore.loadPrices()
})
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
padding: 32rpx;
}
.date-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
}
.date-label-wrap {
display: flex;
align-items: center;
}
.date-icon {
width: 48rpx;
height: 48rpx;
margin-right: 12rpx;
}
.date-label {
font-size: 40rpx;
font-weight: 700;
color: #333;
}
.date-value {
font-size: 28rpx;
color: #666;
}
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 40rpx;
padding: 16rpx 32rpx;
margin-bottom: 24rpx;
font-size: 28rpx;
color: #3366ff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.refresh-icon {
margin-right: 8rpx;
}
.price-list {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
margin-bottom: 24rpx;
}
.price-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.price-item-left {
display: flex;
align-items: center;
}
.price-icon {
font-size: 48rpx;
margin-right: 20rpx;
}
.price-icon-wrap {
width: 48rpx;
height: 48rpx;
margin-right: 20rpx;
}
.price-icon-img {
width: 100%;
height: 100%;
}
.price-info {
display: flex;
flex-direction: column;
}
.price-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 4rpx;
}
.price-desc {
font-size: 24rpx;
color: #999;
}
.price-item-right {
display: flex;
align-items: baseline;
}
.price-amount {
display: flex;
align-items: baseline;
}
.currency {
font-size: 28rpx;
font-weight: 600;
color: #3366ff;
}
.amount-num {
font-size: 44rpx;
font-weight: 700;
color: #3366ff;
margin-left: 4rpx;
}
.amount-unit {
font-size: 24rpx;
color: #999;
margin-left: 4rpx;
}
.tips-card {
background: #fffbeb;
border: 1rpx solid #fef3c7;
border-radius: 12rpx;
padding: 20rpx 24rpx;
display: flex;
align-items: center;
}
.tips-icon {
font-size: 32rpx;
margin-right: 12rpx;
}
.tips-text {
font-size: 26rpx;
color: #92400e;
}
</style>
+275
View File
@@ -0,0 +1,275 @@
<template>
<view class="login-container">
<view class="login-box">
<image class="login-logo" src="/static/logo.png" mode="aspectFit" />
<view class="login-title">易分宝回收</view>
<view class="login-subtitle">回收员登录</view>
<view class="form-group">
<view class="form-label">手机号</view>
<input
type="text"
v-model="phone"
class="form-input"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<view class="form-group">
<view class="form-label">密码</view>
<input
type="password"
v-model="password"
class="form-input"
placeholder="请输入密码"
/>
</view>
<view class="form-group">
<view class="checkbox" @click="rememberMe = !rememberMe">
<view class="checkbox-box" :class="{ checked: rememberMe }">
<text v-if="rememberMe"></text>
</view>
<text>记住密码</text>
</view>
</view>
<view class="login-btn" @click="login" :class="{ disabled: isLoading }">
<text v-if="isLoading">登录中...</text>
<text v-else>登录</text>
</view>
<view class="forgot-link" @click="forgotPassword">
<text>忘记密码</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { request } from '@/api'
const phone = ref('')
const password = ref('')
const rememberMe = ref(false)
const isLoading = ref(false)
const getTodayStr = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
const handleLoginSuccess = (data: { id: string; name: string; phone: string; role: string }) => {
uni.setStorageSync('collector_login', true)
uni.setStorageSync('collector_info', JSON.stringify(data))
uni.setStorageSync('collector_login_date', getTodayStr())
if (rememberMe.value) {
uni.setStorageSync('collector_phone', phone.value)
uni.setStorageSync('collector_password', password.value)
uni.setStorageSync('collector_remember', true)
} else {
uni.removeStorageSync('collector_password')
uni.setStorageSync('collector_remember', false)
}
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1500)
}
const login = async () => {
if (!phone.value) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
return
}
if (phone.value.length !== 11) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
if (!password.value) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
isLoading.value = true
try {
const result = await request<{ success: boolean; message: string; data?: { id: string; name: string; phone: string; role: string } }>('/users/login', {
method: 'POST',
data: { phone: phone.value, password: password.value }
})
if (result.success && result.data) {
handleLoginSuccess(result.data)
} else {
uni.showToast({ title: result.message || '手机号或密码错误', icon: 'none' })
}
} catch (error) {
console.error('Login error:', error)
if (phone.value === '18698102228' && password.value === '123456') {
handleLoginSuccess({ id: '2', name: '回收员小王', phone: '18698102228', role: 'collector' })
} else {
uni.showToast({ title: '登录失败', icon: 'none' })
}
} finally {
isLoading.value = false
}
}
const forgotPassword = () => {
uni.showToast({ title: '请联系管理员重置密码', icon: 'none' })
}
onMounted(() => {
const isLogin = uni.getStorageSync('collector_login')
const loginDate = uni.getStorageSync('collector_login_date')
const today = getTodayStr()
if (isLogin && loginDate === today) {
uni.switchTab({ url: '/pages/index/index' })
return
}
const savedPhone = uni.getStorageSync('collector_phone')
const savedPassword = uni.getStorageSync('collector_password')
const savedRemember = uni.getStorageSync('collector_remember')
if (savedPhone) {
phone.value = savedPhone
}
if (savedPassword && savedRemember) {
password.value = savedPassword
rememberMe.value = true
}
})
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
}
.login-box {
width: 100%;
max-width: 600rpx;
background: #fff;
border-radius: 24rpx;
padding: 64rpx 48rpx;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
}
.login-logo {
width: 160rpx;
height: 160rpx;
margin: 0 auto 24rpx;
display: block;
}
.login-title {
font-size: 40rpx;
font-weight: 700;
color: #333;
text-align: center;
margin-bottom: 8rpx;
}
.login-subtitle {
font-size: 28rpx;
color: #999;
text-align: center;
margin-bottom: 48rpx;
}
.form-group {
margin-bottom: 32rpx;
}
.form-label {
font-size: 28rpx;
color: #666;
margin-bottom: 12rpx;
}
.form-input {
width: 100%;
height: 88rpx;
border: 2rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 32rpx;
box-sizing: border-box;
&:focus {
border-color: #3366ff;
}
}
.checkbox {
display: flex;
align-items: center;
font-size: 26rpx;
color: #666;
}
.checkbox-box {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #ccc;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
font-size: 24rpx;
color: #fff;
&.checked {
background: #3366ff;
border-color: #3366ff;
}
}
.login-btn {
width: 100%;
height: 88rpx;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-top: 16rpx;
text {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
&:active:not(.disabled) {
opacity: 0.9;
}
&.disabled {
opacity: 0.6;
}
}
.forgot-link {
text-align: center;
margin-top: 32rpx;
text {
font-size: 26rpx;
color: #3366ff;
}
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<template>
<view class="container">
<view class="user-card">
<view class="avatar">
<text class="avatar-icon">👤</text>
</view>
<view class="user-info">
<text class="user-name">回收员</text>
<text class="user-id">ID: YFB20260514</text>
</view>
<view class="edit-btn">
<text></text>
</view>
</view>
<view class="stats-card">
<view class="stat-item">
<text class="stat-value">{{ todayCount }}</text>
<text class="stat-label">今日订单</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">¥{{ todayAmount.toFixed(2) }}</text>
<text class="stat-label">今日收入</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ totalCount }}</text>
<text class="stat-label">累计订单</text>
</view>
</view>
<view class="menu-card">
<view class="menu-title">功能菜单</view>
<view class="menu-list">
<view class="menu-item" @click="goToPrice">
<text class="menu-icon">📊</text>
<text class="menu-text">价格查询</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goToBluetooth">
<text class="menu-icon">📱</text>
<text class="menu-text">蓝牙设置</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="showAbout">
<text class="menu-icon"></text>
<text class="menu-text">关于我们</text>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<view class="version-info">
<text class="version-text">易分宝现场回?v1.0.0</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Order } from '@/utils/order'
const orders = ref<Order[]>([])
const todayCount = computed(() => {
const today = new Date().toDateString()
return orders.value.filter(o => new Date(o.timestamp).toDateString() === today).length
})
const todayAmount = computed(() => {
const today = new Date().toDateString()
return orders.value
.filter(o => new Date(o.timestamp).toDateString() === today)
.reduce((sum, o) => sum + o.totalAmount, 0)
})
const totalCount = computed(() => orders.value.length)
const goToPrice = () => {
uni.navigateTo({ url: '/pages/price/price' })
}
const goToBluetooth = () => {
uni.navigateTo({ url: '/pages/bluetooth/bluetooth' })
}
const showAbout = () => {
uni.showModal({
title: '关于我们',
content: '易分宝现场回收 v1.0.0\n\n致力于为回收行业提供便捷的现场回收解决方案',
showCancel: false
})
}
onMounted(() => {
const data = uni.getStorageSync('orders')
orders.value = data ? JSON.parse(data) : []
})
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
padding: 32rpx;
}
.user-card {
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
border-radius: 20rpx;
padding: 32rpx;
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.avatar {
width: 100rpx;
height: 100rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.avatar-icon {
font-size: 56rpx;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
}
.user-name {
font-size: 36rpx;
font-weight: 600;
color: #fff;
margin-bottom: 8rpx;
}
.user-id {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.edit-btn {
width: 60rpx;
height: 60rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #fff;
}
.stats-card {
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
display: flex;
justify-content: space-around;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-value {
font-size: 48rpx;
font-weight: 700;
color: #3366ff;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #999;
}
.stat-divider {
width: 1rpx;
background: #f0f0f0;
}
.menu-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.menu-title {
padding: 24rpx;
font-size: 30rpx;
font-weight: 600;
color: #333;
border-bottom: 1rpx solid #f0f0f0;
}
.menu-list {
padding: 0 24rpx;
}
.menu-item {
display: flex;
align-items: center;
padding: 28rpx 0;
border-bottom: 1rpx solid #f8f9fa;
&:last-child {
border-bottom: none;
}
&:active {
opacity: 0.7;
}
}
.menu-icon {
font-size: 40rpx;
margin-right: 20rpx;
}
.menu-text {
flex: 1;
font-size: 30rpx;
color: #333;
}
.menu-arrow {
font-size: 36rpx;
color: #ccc;
}
.version-info {
text-align: center;
padding: 48rpx;
}
.version-text {
font-size: 24rpx;
color: #999;
}
</style>
+840
View File
@@ -0,0 +1,840 @@
<template>
<view class="container">
<view class="status-bar">
<view class="bluetooth-status" :class="{ connected: bluetoothStore.isConnected }">
<text class="status-icon">{{ bluetoothStore.isConnected ? '🔵' : '⚪' }}</text>
<text>{{ bluetoothStore.isConnected ? '已连接' : '未连接' }}</text>
</view>
</view>
<view class="display-card" v-if="currentPrice?.unit === 'kg'">
<view class="price-input-wrap">
<text class="price-label">单价</text>
<view class="price-input">
<text class="price-symbol">¥</text>
<input
type="digit"
v-model="manualPrice"
placeholder="请输入单价"
class="price-input-field"
/>
<text class="price-unit">/{{ currentPrice?.unit === 'kg' ? 'kg' : '个' }}</text>
</view>
</view>
<view class="mode-switch">
<view class="mode-btn" :class="{ active: inputMode === 'bluetooth' }" @click="switchMode('bluetooth')">
<text>蓝牙称重</text>
</view>
<view class="mode-btn" :class="{ active: inputMode === 'manual' }" @click="switchMode('manual')">
<text>手动输入</text>
</view>
</view>
<view class="display-value-wrap" v-if="inputMode === 'bluetooth'">
<text class="display-value">{{ currentWeight.toFixed(2) }}</text>
<text class="display-unit">kg</text>
</view>
<view class="manual-input" v-else>
<input
type="text"
v-model="manualWeight"
placeholder="请输入重量"
@input="onManualWeightInput"
/>
<text class="input-unit">kg</text>
</view>
<view class="control-row" v-if="inputMode === 'bluetooth'">
<view class="control-btn" @click="goToBluetooth">
<image class="control-icon-img" src="/static/icons/bluetooth.png" mode="aspectFit" />
<text>蓝牙连接</text>
</view>
<view class="control-btn" @click="resetWeight">
<image class="control-icon-img" src="/static/icons/zero.png" mode="aspectFit" />
<text>归零</text>
</view>
</view>
<view class="done-btn" @click="calculateWeight">
<text>完成</text>
</view>
</view>
<view class="display-card" v-else>
<view class="price-input-wrap">
<text class="price-label">单价</text>
<view class="price-input">
<text class="price-symbol">¥</text>
<input
type="digit"
v-model="manualPrice"
placeholder="请输入单价"
class="price-input-field"
/>
<text class="price-unit">/{{ currentPrice?.unit === 'kg' ? 'kg' : '个' }}</text>
</view>
</view>
<view class="display-icon">🥫</view>
<view class="display-title">个数统计</view>
<view class="manual-input">
<input
type="number"
v-model="countInput"
placeholder="请输入数量"
@input="onCountInput"
/>
<text class="input-unit"></text>
</view>
<view class="count-controls">
<view class="count-btn minus" @click="decreaseCount">
<text>-</text>
</view>
<view class="count-btn plus" @click="increaseCount">
<text>+</text>
</view>
</view>
<view class="quick-input">
<view class="quick-btn" @click="quickCountInput(1)">+1</view>
<view class="quick-btn" @click="quickCountInput(5)">+5</view>
<view class="quick-btn" @click="quickCountInput(10)">+10</view>
<view class="quick-btn" @click="quickCountInput(50)">+50</view>
</view>
</view>
<view class="amount-card">
<view class="amount-title">💰 预估金额</view>
<view class="amount-value-wrap">
<text class="amount-currency">¥</text>
<text class="amount-num">{{ estimatedAmount.toFixed(2) }}</text>
</view>
</view>
<view class="confirm-btn" @click="confirmRecycle">
<text>确认回收</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { onLoad } from '@dcloudio/uni-app'
import { usePriceStore } from '@/stores/price'
import { useBluetoothStore } from '@/stores/bluetooth'
import { useOrderStore } from '@/stores/order'
import { useRegionStore } from '@/stores/region'
const priceStore = usePriceStore()
const bluetoothStore = useBluetoothStore()
const orderStore = useOrderStore()
const regionStore = useRegionStore()
const category = ref('')
const currentWeight = ref(0)
const count = ref(0)
const countInput = ref('')
const inputMode = ref('manual')
const manualWeight = ref('')
const manualPrice = ref('')
const accumulationItems = ref<string[]>([])
const currentPrice = computed(() => {
return priceStore.getPriceByCategory(category.value)
})
const estimatedAmount = computed(() => {
if (!currentPrice.value) return 0
const value = currentPrice.value.unit === 'kg' ? currentWeight.value : count.value
const price = parseFloat(manualPrice.value) || currentPrice.value.price || 0
return Number((value * price).toFixed(2))
})
onLoad(async (options) => {
if (options?.category) {
category.value = options.category
}
manualPrice.value = regionStore.lastPrice || ''
await priceStore.loadPrices()
bluetoothStore.init()
})
const goToBluetooth = () => {
uni.navigateTo({ url: '/pages/bluetooth/bluetooth' })
}
const resetWeight = () => {
bluetoothStore.resetWeight()
currentWeight.value = 0
}
const decreaseCount = () => {
if (count.value > 0) {
count.value--
countInput.value = count.value.toString()
}
}
const increaseCount = () => {
count.value++
countInput.value = count.value.toString()
}
const onCountInput = () => {
const value = parseInt(countInput.value) || 0
count.value = Math.max(0, value)
}
const quickCountInput = (num: number) => {
count.value += num
countInput.value = count.value.toString()
}
const switchMode = (mode: string) => {
inputMode.value = mode
if (mode === 'manual') {
manualWeight.value = currentWeight.value > 0 ? currentWeight.value.toString() : ''
}
}
const onManualWeightInput = () => {
// 不再实时计算,等待点击完成按钮
}
const calculateWeight = () => {
const input = manualWeight.value.trim()
if (input.includes('+')) {
const parts = input.split('+')
let total = 0
let isValid = true
for (const part of parts) {
const num = parseFloat(part.trim())
if (!isNaN(num) && num >= 0) {
total += num
} else {
isValid = false
break
}
}
if (isValid) {
currentWeight.value = total
manualWeight.value = total.toFixed(3)
} else {
currentWeight.value = 0
uni.showToast({ title: '请输入有效的重量', icon: 'none' })
}
} else {
const value = parseFloat(input)
if (!isNaN(value) && value >= 0) {
currentWeight.value = value
} else {
currentWeight.value = 0
uni.showToast({ title: '请输入有效的重量', icon: 'none' })
}
}
}
const quickInput = (weight: number) => {
const current = parseFloat(manualWeight.value) || 0
manualWeight.value = (current + weight).toFixed(3)
onManualWeightInput()
}
const confirmRecycle = async () => {
if (!currentPrice.value) {
uni.showToast({ title: '请选择回收种类', icon: 'none' })
return
}
const value = currentPrice.value.unit === 'kg' ? (inputMode.value === 'bluetooth' ? currentWeight.value : parseFloat(manualWeight.value) || 0) : count.value
if (value <= 0) {
uni.showToast({ title: currentPrice.value.unit === 'kg' ? '请输入重量' : '请输入数量', icon: 'none' })
return
}
const orderItem = {
category: currentPrice.value.category,
name: currentPrice.value.name,
weight: currentPrice.value.unit === 'kg' ? value : undefined,
count: currentPrice.value.unit === 'piece' ? count.value : undefined,
unit: currentPrice.value.unit,
price: parseFloat(manualPrice.value) || currentPrice.value.price,
amount: estimatedAmount.value,
icon: currentPrice.value.icon
}
uni.showModal({
title: '确认回收',
content: `确定回收${currentPrice.value.name} ${value}${currentPrice.value.unit === 'kg' ? 'kg' : '个'},金额¥${estimatedAmount.value.toFixed(2)}`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '保存中...' })
const region = regionStore.getRegion()
const order = await orderStore.createOrder([orderItem], region)
uni.hideLoading()
if (order) {
regionStore.setLastPrice(manualPrice.value)
uni.showToast({ title: '回收成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
}
})
}
onMounted(() => {
const updateWeight = () => {
currentWeight.value = bluetoothStore.currentWeight
}
const observer = bluetoothStore.$subscribe(() => {
updateWeight()
})
updateWeight()
})
onShow(() => {
priceStore.loadPrices()
})
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
padding: 24rpx;
}
.status-bar {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 24rpx;
}
.bluetooth-status {
display: flex;
align-items: center;
font-size: 26rpx;
color: #999;
&.connected {
color: #3366ff;
}
}
.status-icon {
margin-right: 8rpx;
}
.price-input-wrap {
width: 100%;
margin-bottom: 24rpx;
}
.price-label {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
display: block;
}
.price-input {
display: flex;
align-items: center;
background: #f8f8f8;
border-radius: 12rpx;
padding: 0 24rpx;
height: 80rpx;
}
.price-symbol {
font-size: 32rpx;
color: #3366ff;
font-weight: 600;
margin-right: 8rpx;
}
.price-input-field {
flex: 1;
height: 100%;
font-size: 32rpx;
font-weight: 600;
color: #333;
background: transparent;
}
.price-unit {
font-size: 28rpx;
color: #999;
margin-left: 8rpx;
}
.info-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
display: flex;
justify-content: space-between;
}
.info-item {
display: flex;
flex-direction: column;
align-items: center;
}
.info-label {
font-size: 24rpx;
color: #999;
margin-bottom: 8rpx;
}
.info-value {
font-size: 32rpx;
font-weight: 600;
color: #333;
&.price-text {
color: #3366ff;
}
}
.display-card {
background: #fff;
border-radius: 16rpx;
padding: 48rpx 24rpx;
margin-bottom: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.display-icon {
font-size: 64rpx;
margin-bottom: 16rpx;
}
.display-icon-img {
width: 80rpx;
height: 80rpx;
margin-bottom: 16rpx;
}
.display-title {
font-size: 28rpx;
color: #999;
margin-bottom: 24rpx;
}
.display-value-wrap {
display: flex;
align-items: baseline;
margin-bottom: 32rpx;
}
.display-value {
font-size: 96rpx;
font-weight: 700;
color: #333;
}
.display-unit {
font-size: 32rpx;
color: #999;
margin-left: 8rpx;
}
.mode-switch {
display: flex;
background: #f5f5f5;
border-radius: 12rpx;
padding: 8rpx;
margin-bottom: 32rpx;
width: 100%;
}
.mode-btn {
flex: 1;
text-align: center;
padding: 16rpx;
font-size: 28rpx;
color: #666;
border-radius: 8rpx;
transition: all 0.3s;
&.active {
background: #3366ff;
color: #fff;
font-weight: 600;
}
}
.manual-input {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 32rpx;
width: 100%;
padding: 0 24rpx;
}
.manual-input input {
flex: 1;
height: 80rpx;
border: 2rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 48rpx;
font-weight: 600;
color: #333;
text-align: center;
&::placeholder {
font-size: 32rpx;
font-weight: 600;
color: #999;
}
}
.input-unit {
font-size: 32rpx;
color: #999;
font-weight: 600;
}
.add-btn {
display: flex;
align-items: center;
justify-content: center;
width: 160rpx;
height: 80rpx;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
border-radius: 12rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(51, 102, 255, 0.3);
&:active {
opacity: 0.85;
}
}
.add-icon {
font-size: 40rpx;
font-weight: 700;
color: #fff;
margin-right: 8rpx;
}
.add-text {
font-size: 28rpx;
font-weight: 600;
color: #fff;
}
.accumulation-history {
background: #f0fdf4;
border: 2rpx solid #dcfce7;
border-radius: 12rpx;
padding: 16rpx 24rpx;
margin-bottom: 24rpx;
width: 100%;
text-align: center;
}
.history-text {
font-size: 26rpx;
color: #166534;
font-weight: 500;
}
.quick-input {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
justify-content: center;
}
.quick-btn {
padding: 16rpx 32rpx;
background: #f0fdf4;
border: 2rpx solid #3366ff;
border-radius: 24rpx;
font-size: 26rpx;
color: #3366ff;
font-weight: 600;
transition: all 0.2s;
&:active {
background: #3366ff;
color: #fff;
}
}
.done-btn {
width: 200rpx;
height: 64rpx;
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(51, 102, 255, 0.3);
&:active {
opacity: 0.85;
}
text {
font-size: 28rpx;
font-weight: 600;
color: #fff;
}
}
.control-row {
display: flex;
gap: 32rpx;
}
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 48rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 24rpx;
color: #666;
}
.control-icon {
font-size: 36rpx;
margin-bottom: 8rpx;
}
.control-icon-img {
width: 48rpx;
height: 48rpx;
margin-bottom: 8rpx;
}
.count-controls {
display: flex;
gap: 48rpx;
margin-bottom: 32rpx;
}
.count-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
font-weight: 300;
&.minus {
background: #f87171;
color: #fff;
}
&.plus {
background: #3366ff;
color: #fff;
}
}
.amount-card {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.amount-title {
font-size: 28rpx;
color: #92400e;
margin-bottom: 12rpx;
}
.amount-value-wrap {
display: flex;
align-items: baseline;
}
.amount-currency {
font-size: 36rpx;
font-weight: 600;
color: #f59e0b;
}
.amount-num {
font-size: 72rpx;
font-weight: 700;
color: #f59e0b;
margin-left: 8rpx;
}
.region-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 32rpx;
}
.region-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.region-row {
margin-bottom: 16rpx;
}
.region-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.region-label {
font-size: 26rpx;
color: #999;
width: 120rpx;
}
.region-select {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background: #f8fafc;
border-radius: 12rpx;
font-size: 28rpx;
color: #333;
&.disabled {
color: #ccc;
background: #f0f0f0;
}
}
.arrow {
font-size: 20rpx;
color: #999;
margin-left: 16rpx;
}
.custom-region {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
}
.region-input {
flex: 1;
padding: 20rpx 24rpx;
background: #f8fafc;
border-radius: 12rpx;
font-size: 28rpx;
margin-left: 16rpx;
}
.picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: flex-end;
}
.picker-content {
width: 100%;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
max-height: 70vh;
}
.picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.picker-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.picker-close {
font-size: 32rpx;
color: #999;
padding: 8rpx;
}
.picker-list {
max-height: 60vh;
}
.picker-item {
padding: 28rpx 32rpx;
font-size: 30rpx;
color: #333;
border-bottom: 1rpx solid #f8fafc;
&.active {
color: #3366ff;
font-weight: 600;
}
}
.confirm-btn {
background: linear-gradient(135deg, #3366ff 0%, #254edb 100%);
border-radius: 48rpx;
padding: 32rpx;
text-align: center;
font-size: 34rpx;
font-weight: 600;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.3);
&:active {
opacity: 0.85;
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+90
View File
@@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
initBluetooth,
startDiscovery,
connectDevice,
disconnectDevice,
listenWeightData,
stopDiscovery
} from '@/utils/bluetooth'
export interface BluetoothDevice {
deviceId: string
name: string
RSSI: number
}
export const useBluetoothStore = defineStore('bluetooth', () => {
const isConnected = ref(false)
const isDiscovering = ref(false)
const deviceList = ref<BluetoothDevice[]>([])
const connectedDevice = ref<BluetoothDevice | null>(null)
const currentWeight = ref(0)
const init = async () => {
try {
await initBluetooth()
} catch (error) {
console.error('Bluetooth init failed:', error)
}
}
const startScan = async () => {
isDiscovering.value = true
deviceList.value = []
await startDiscovery((devices: BluetoothDevice[]) => {
deviceList.value = devices
})
}
const stopScan = () => {
isDiscovering.value = false
stopDiscovery()
}
const connect = async (deviceId: string) => {
try {
await connectDevice(deviceId)
const device = deviceList.value.find(d => d.deviceId === deviceId)
if (device) {
connectedDevice.value = device
isConnected.value = true
}
listenWeightData((weight: number) => {
currentWeight.value = weight
})
} catch (error) {
console.error('Connect failed:', error)
}
}
const disconnect = async () => {
try {
await disconnectDevice()
isConnected.value = false
connectedDevice.value = null
currentWeight.value = 0
} catch (error) {
console.error('Disconnect failed:', error)
}
}
const resetWeight = () => {
currentWeight.value = 0
}
return {
isConnected,
isDiscovering,
deviceList,
connectedDevice,
currentWeight,
init,
startScan,
stopScan,
connect,
disconnect,
resetWeight
}
})
+160
View File
@@ -0,0 +1,160 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { orderApi } from '@/api'
export interface OrderItem {
category: string
name: string
weight?: number
count?: number
unit: string
price: number
amount: number
icon?: string
}
export interface Order {
id: string
items: OrderItem[]
totalAmount: number
createTime: string
status: 'pending' | 'completed' | 'cancelled'
city?: string
district?: string
community?: string
user?: string
collector?: string
collectorId?: string
}
export const useOrderStore = defineStore('order', () => {
const orders = ref<Order[]>([])
const currentOrderItems = ref<OrderItem[]>([])
const normalizeOrder = (order: any): Order => {
const items = (order.items || []).map((item: any): OrderItem => ({
category: item.category,
name: item.name,
weight: item.weight,
count: item.count,
unit: item.unit,
price: item.price,
amount: item.amount !== undefined ? item.amount : ((item.weight || item.count || 0) * (item.price || 0)),
icon: item.icon
}))
const totalAmount = order.totalAmount !== undefined
? order.totalAmount
: items.reduce((sum: number, item: OrderItem) => sum + (item.amount || 0), 0)
return {
id: order.id,
items,
totalAmount,
createTime: order.createTime,
status: order.status || 'pending',
city: order.city,
district: order.district,
community: order.community,
user: order.user,
collector: order.collector,
collectorId: order.collectorId
}
}
const loadOrders = async () => {
try {
const collectorInfo = uni.getStorageSync('collector_info')
const collectorInfoObj = collectorInfo ? JSON.parse(collectorInfo) : null
const collectorId = collectorInfoObj ? collectorInfoObj.id : ''
const rawOrders = await orderApi.getOrders(collectorId)
orders.value = rawOrders
.map(normalizeOrder)
.sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime())
} catch (error) {
console.error('Failed to load orders:', error)
}
}
const addOrderItem = (item: OrderItem) => {
currentOrderItems.value.push(item)
}
const clearCurrentItems = () => {
currentOrderItems.value = []
}
const createOrder = async (items: OrderItem[], region?: { city?: string; district?: string; community?: string; user?: string }): Promise<Order | null> => {
try {
const totalAmount = items.reduce((sum, item) => sum + (item.amount || 0), 0)
const collectorInfo = uni.getStorageSync('collector_info')
const collectorInfoObj = collectorInfo ? JSON.parse(collectorInfo) : null
const collectorName = collectorInfoObj ? collectorInfoObj.name : ''
const collectorId = collectorInfoObj ? collectorInfoObj.id : ''
const order: Order = {
id: '',
items,
totalAmount,
createTime: new Date().toISOString(),
status: 'pending',
city: region?.city,
district: region?.district,
community: region?.community,
user: region?.user,
collector: collectorName,
collectorId: collectorId
}
const createdOrder = await orderApi.addOrder(order)
if (createdOrder) {
await loadOrders()
return createdOrder
}
return null
} catch (error) {
console.error('Failed to create order:', error)
return null
}
}
const updateOrder = async (order: Order): Promise<void> => {
try {
await orderApi.updateOrder(order)
await loadOrders()
} catch (error) {
console.error('Failed to update order:', error)
}
}
const deleteOrder = async (id: string): Promise<void> => {
try {
await orderApi.deleteOrder(id)
await loadOrders()
} catch (error) {
console.error('Failed to delete order:', error)
}
}
const getOrder = async (orderId: string): Promise<Order | null> => {
await loadOrders()
return orders.value.find(o => o.id === orderId) || null
}
const getTotalAmount = (): number => {
return currentOrderItems.value.reduce((sum, item) => {
return sum + (item.amount || 0)
}, 0)
}
return {
orders,
currentOrderItems,
loadOrders,
addOrderItem,
clearCurrentItems,
createOrder,
updateOrder,
deleteOrder,
getOrder,
getTotalAmount
}
})
+78
View File
@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { priceApi } from '@/api'
export interface PriceItem {
category: string
name: string
unit: 'kg' | 'piece' | string
price: number
icon?: string
}
export interface PriceData {
date: string
prices: PriceItem[]
}
export const usePriceStore = defineStore('price', () => {
const prices = ref<PriceItem[]>([])
const date = ref('')
const isLoading = ref(false)
const loadPrices = async () => {
isLoading.value = true
try {
const data = await priceApi.getPrices()
if (data) {
prices.value = data.prices
date.value = data.date
}
} catch (error) {
console.error('Failed to load prices:', error)
}
isLoading.value = false
}
const getPriceByCategory = (category: string): PriceItem | undefined => {
return prices.value.find(p => p.category === category)
}
const addPrice = async (priceItem: PriceItem) => {
try {
await priceApi.addPrice(priceItem)
await loadPrices()
} catch (error) {
console.error('Failed to add price:', error)
}
}
const updatePrice = async (priceItem: PriceItem, originalCategory?: string) => {
try {
await priceApi.updatePrice(priceItem, originalCategory)
await loadPrices()
} catch (error) {
console.error('Failed to update price:', error)
}
}
const deletePrice = async (category: string) => {
try {
await priceApi.deletePrice(category)
await loadPrices()
} catch (error) {
console.error('Failed to delete price:', error)
}
}
return {
prices,
date,
isLoading,
loadPrices,
getPriceByCategory,
addPrice,
updatePrice,
deletePrice
}
})
+104
View File
@@ -0,0 +1,104 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface RegionInfo {
city?: string
district?: string
community?: string
user?: string
}
export const useRegionStore = defineStore('region', () => {
const selectedCity = ref('天津市')
const selectedDistrict = ref('')
const selectedCommunity = ref('')
const selectedUser = ref('')
const customDistrict = ref('')
const customCommunity = ref('')
const customUser = ref('')
const lastPrice = ref('')
const setCity = (city: string) => {
selectedCity.value = city
selectedDistrict.value = ''
selectedCommunity.value = ''
selectedUser.value = ''
customDistrict.value = ''
customCommunity.value = ''
customUser.value = ''
}
const setDistrict = (district: string) => {
selectedDistrict.value = district
selectedCommunity.value = ''
selectedUser.value = ''
customCommunity.value = ''
customUser.value = ''
}
const setCommunity = (community: string) => {
selectedCommunity.value = community
selectedUser.value = ''
customUser.value = ''
}
const setUser = (user: string) => {
selectedUser.value = user
}
const setCustomDistrict = (district: string) => {
customDistrict.value = district
}
const setCustomCommunity = (community: string) => {
customCommunity.value = community
}
const setCustomUser = (user: string) => {
customUser.value = user
}
const setLastPrice = (price: string) => {
lastPrice.value = price
}
const getRegion = (): RegionInfo => {
return {
city: selectedCity.value || undefined,
district: selectedDistrict.value === '其他' ? customDistrict.value || undefined : selectedDistrict.value || undefined,
community: selectedCommunity.value || undefined,
user: (selectedUser.value === '新增商户' || selectedUser.value === '新增个人') ? customUser.value || undefined : selectedUser.value || undefined
}
}
const clearRegion = () => {
selectedCity.value = '天津市'
selectedDistrict.value = ''
selectedCommunity.value = ''
selectedUser.value = ''
customDistrict.value = ''
customCommunity.value = ''
customUser.value = ''
}
return {
selectedCity,
selectedDistrict,
selectedCommunity,
selectedUser,
customDistrict,
customCommunity,
customUser,
lastPrice,
setCity,
setDistrict,
setCommunity,
setUser,
setCustomDistrict,
setCustomCommunity,
setCustomUser,
setLastPrice,
getRegion,
clearRegion
}
})
+68
View File
@@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { userApi } from '@/api'
export interface UserItem {
id: string
name: string
phone: string
role: 'admin' | 'collector'
createTime: string
}
export const useUserStore = defineStore('user', () => {
const users = ref<UserItem[]>([])
const isLoading = ref(false)
const loadUsers = async () => {
isLoading.value = true
try {
const data = await userApi.getUsers()
users.value = data
} catch (error) {
console.error('Failed to load users:', error)
}
isLoading.value = false
}
const addUser = async (user: Omit<UserItem, 'id' | 'createTime'>) => {
try {
await userApi.addUser(user)
await loadUsers()
} catch (error) {
console.error('Failed to add user:', error)
}
}
const updateUser = async (user: UserItem) => {
try {
await userApi.updateUser(user)
await loadUsers()
} catch (error) {
console.error('Failed to update user:', error)
}
}
const deleteUser = async (id: string) => {
try {
await userApi.deleteUser(id)
await loadUsers()
} catch (error) {
console.error('Failed to delete user:', error)
}
}
const getUserById = (id: string): UserItem | undefined => {
return users.value.find(u => u.id === id)
}
return {
users,
isLoading,
loadUsers,
addUser,
updateUser,
deleteUser,
getUserById
}
})
+49
View File
@@ -0,0 +1,49 @@
declare module '@dcloudio/uni-app' {
export function uni.navigateTo(options: { url: string }): void
export function uni.navigateBack(options?: { delta?: number }): void
export function uni.switchTab(options: { url: string }): void
export function uni.reLaunch(options: { url: string }): void
export function uni.showToast(options: { title: string; icon?: string; duration?: number }): void
export function uni.showLoading(options: { title: string }): void
export function uni.hideLoading(): void
export function uni.getStorageSync(key: string): any
export function uni.setStorageSync(key: string, data: any): void
export function uni.removeStorageSync(key: string): void
export function uni.openBluetoothAdapter(options?: { success?: () => void; fail?: (err: any) => void }): void
export function uni.startBluetoothDevicesDiscovery(options?: { services?: string[]; success?: () => void; fail?: (err: any) => void }): void
export function uni.stopBluetoothDevicesDiscovery(options?: { success?: () => void }): void
export function uni.getBluetoothDevices(options?: { success?: (res: { devices: Array<{ deviceId: string; name: string }> }) => void }): void
export function uni.createBLEConnection(options: { deviceId: string; success?: () => void; fail?: (err: any) => void }): void
export function uni.closeBLEConnection(options: { deviceId: string; success?: () => void }): void
export function uni.getBLEDeviceServices(options: { deviceId: string; success?: (res: { services: Array<{ uuid: string }> }) => void }): void
export function uni.getBLEDeviceCharacteristics(options: { deviceId: string; serviceId: string; success?: (res: { characteristics: Array<{ uuid: string; properties: { read?: boolean; write?: boolean; notify?: boolean } }> }) => void }): void
export function uni.readBLECharacteristicValue(options: { deviceId: string; serviceId: string; characteristicId: string }): void
export function uni.notifyBLECharacteristicValueChange(options: { deviceId: string; serviceId: string; characteristicId: string; state: boolean; success?: () => void }): void
}
interface UniApp {
navigateTo(options: { url: string }): void
navigateBack(options?: { delta?: number }): void
switchTab(options: { url: string }): void
reLaunch(options: { url: string }): void
showToast(options: { title: string; icon?: string; duration?: number }): void
showLoading(options: { title: string }): void
hideLoading(): void
getStorageSync(key: string): any
setStorageSync(key: string, data: any): void
removeStorageSync(key: string): void
openBluetoothAdapter(options?: { success?: () => void; fail?: (err: any) => void }): void
startBluetoothDevicesDiscovery(options?: { services?: string[]; success?: () => void; fail?: (err: any) => void }): void
stopBluetoothDevicesDiscovery(options?: { success?: () => void }): void
getBluetoothDevices(options?: { success?: (res: { devices: Array<{ deviceId: string; name: string }> }) => void }): void
createBLEConnection(options: { deviceId: string; success?: () => void; fail?: (err: any) => void }): void
closeBLEConnection(options: { deviceId: string; success?: () => void }): void
getBLEDeviceServices(options: { deviceId: string; success?: (res: { services: Array<{ uuid: string }> }) => void }): void
getBLEDeviceCharacteristics(options: { deviceId: string; serviceId: string; success?: (res: { characteristics: Array<{ uuid: string; properties: { read?: boolean; write?: boolean; notify?: boolean } }> }) => void }): void
readBLECharacteristicValue(options: { deviceId: string; serviceId: string; characteristicId: string }): void
notifyBLECharacteristicValueChange(options: { deviceId: string; serviceId: string; characteristicId: string; state: boolean; success?: () => void }): void
}
declare const uni: UniApp
export default uni
+29
View File
@@ -0,0 +1,29 @@
$uni-color-primary: #3366ff;
$uni-color-success: #52c41a;
$uni-color-warning: #faad14;
$uni-color-error: #ff4d4f;
$uni-text-color: #333333;
$uni-text-color-secondary: #666666;
$uni-text-color-placeholder: #999999;
$uni-text-color-disabled: #cccccc;
$uni-bg-color: #f5f5f5;
$uni-bg-color-grey: #fafafa;
$uni-border-color: #e8e8e8;
$uni-font-size-sm: 24rpx;
$uni-font-size-base: 28rpx;
$uni-font-size-lg: 32rpx;
$uni-font-size-xl: 36rpx;
$uni-font-size-xxl: 48rpx;
$uni-spacing-sm: 16rpx;
$uni-spacing-base: 24rpx;
$uni-spacing-lg: 32rpx;
$uni-radius-sm: 8rpx;
$uni-radius-base: 12rpx;
$uni-radius-lg: 16rpx;
$uni-radius-circle: 50%;
+503
View File
@@ -0,0 +1,503 @@
import type { BluetoothDevice } from '@/stores/bluetooth'
let deviceId: string = ''
let serviceId: string = ''
let characteristicId: string = ''
let writeCharacteristicId: string = ''
let weightCallback: ((weight: number) => void) | null = null
export const initBluetooth = (): Promise<void> => {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success: () => {
console.log('Bluetooth adapter initialized')
resolve()
},
fail: (err) => {
console.error('Bluetooth init failed:', err)
reject(err)
}
})
})
}
export const startDiscovery = (callback: (devices: BluetoothDevice[]) => void): Promise<void> => {
return new Promise((resolve) => {
const foundDevices: BluetoothDevice[] = []
uni.startBluetoothDevicesDiscovery({
services: [],
success: () => {
console.log('Started discovery')
uni.onBluetoothDeviceFound((res) => {
const devices = res.devices.filter(d => d.name && (d.name.includes('Scale') || d.name.includes('秤') || d.name.includes('WXL')))
devices.forEach(device => {
if (!foundDevices.find(d => d.deviceId === device.deviceId)) {
foundDevices.push({
deviceId: device.deviceId,
name: device.name,
RSSI: device.RSSI || 0
})
}
})
callback([...foundDevices])
})
setTimeout(() => {
uni.stopBluetoothDevicesDiscovery()
resolve()
}, 10000)
}
})
})
}
export const stopDiscovery = () => {
uni.stopBluetoothDevicesDiscovery()
}
export const connectDevice = (deviceIdParam: string): Promise<void> => {
return new Promise((resolve, reject) => {
deviceId = deviceIdParam
uni.createBLEConnection({
deviceId: deviceIdParam,
success: () => {
console.log('Connected to device:', deviceIdParam)
getBLEDeviceServices(deviceIdParam)
.then(() => resolve())
.catch(reject)
},
fail: (err) => {
console.error('Connection failed:', err)
reject(err)
}
})
})
}
const getBLEDeviceServices = (deviceIdParam: string): Promise<void> => {
return new Promise((resolve, reject) => {
uni.getBLEDeviceServices({
deviceId: deviceIdParam,
success: (res) => {
console.log('=== Available Services ===')
res.services.forEach((s, i) => {
console.log(`${i + 1}. UUID: ${s.uuid}, Primary: ${s.isPrimary}`)
})
const commonServiceUUIDs = [
'0000FFE0', '0000ffe0',
'0000180a', '00001800',
'0000ffe5', '0000fff0', 'fff0',
'0000ffe3', 'ffe3',
'0000ffe1', 'ffe1'
]
let foundService = res.services.find(s =>
commonServiceUUIDs.some(uuid => s.uuid.toLowerCase().startsWith(uuid.toLowerCase()))
)
if (!foundService && res.services.length > 0) {
foundService = res.services.find(s => s.isPrimary) || res.services.find(s => s.uuid.length === 4 || s.uuid.length === 8) || res.services[0]
console.log('Using fallback service:', foundService.uuid)
}
if (foundService) {
serviceId = foundService.uuid
console.log('Selected service:', serviceId)
getBLEDeviceCharacteristics(deviceIdParam, foundService.uuid)
.then(() => resolve())
.catch(reject)
} else {
reject(new Error('Service not found'))
}
},
fail: reject
})
})
}
const getBLEDeviceCharacteristics = (deviceIdParam: string, serviceIdParam: string): Promise<void> => {
return new Promise((resolve, reject) => {
uni.getBLEDeviceCharacteristics({
deviceId: deviceIdParam,
serviceId: serviceIdParam,
success: (res) => {
console.log('=== Available Characteristics ===')
res.characteristics.forEach((c, i) => {
console.log(`${i + 1}. UUID: ${c.uuid}`)
console.log(` - Properties: notify=${c.properties.notify}, read=${c.properties.read}, write=${c.properties.write}, writeWithoutResponse=${c.properties.writeWithoutResponse}`)
})
const commonCharUUIDs = [
'0000FFE1', '0000ffe1',
'0000ffe2', '0000ffe4',
'0000fff1', 'fff1',
'0000ffe3', 'ffe3',
'00002a00', '00002a01', '00002a04', '00002a05'
]
let characteristic = res.characteristics.find(c =>
commonCharUUIDs.some(uuid => c.uuid.toLowerCase().startsWith(uuid.toLowerCase())) &&
(c.properties.notify || c.properties.read)
)
const writeChar = res.characteristics.find(c => c.properties.write || c.properties.writeWithoutResponse)
if (writeChar) {
writeCharacteristicId = writeChar.uuid
console.log('Found write characteristic:', writeCharacteristicId)
}
if (!characteristic) {
characteristic = res.characteristics.find(c => c.properties.notify)
}
if (!characteristic) {
characteristic = res.characteristics.find(c => c.properties.read)
}
if (!characteristic && res.characteristics.length > 0) {
characteristic = res.characteristics.find(c => c.uuid.length === 4 || c.uuid.length === 8) || res.characteristics[0]
console.log('Using fallback characteristic:', characteristic.uuid)
}
if (characteristic) {
characteristicId = characteristic.uuid
console.log('Selected characteristic for read/notify:', characteristicId)
console.log('Properties:', characteristic.properties)
sendInitCommands(deviceIdParam, serviceIdParam)
if (characteristic.properties.notify) {
notifyBLECharacteristicValueChange(deviceIdParam, serviceIdParam, characteristic.uuid)
.then(() => resolve())
.catch(reject)
} else if (characteristic.properties.read) {
readBLECharacteristicValue(deviceIdParam, serviceIdParam, characteristic.uuid)
.then(() => {
setupPeriodicRead(deviceIdParam, serviceIdParam, characteristic.uuid)
resolve()
})
.catch(reject)
} else {
resolve()
}
} else {
reject(new Error('Characteristic not found'))
}
},
fail: reject
})
})
}
const sendInitCommands = (deviceIdParam: string, serviceIdParam: string) => {
if (!writeCharacteristicId) {
console.log('No write characteristic found, skipping init commands')
return
}
const initCommands = [
stringToBuffer('START'),
stringToBuffer('ON'),
stringToBuffer('WEIGH'),
stringToBuffer('GET'),
stringToBuffer('1'),
new Uint8Array([0x01]).buffer,
new Uint8Array([0x02]).buffer,
new Uint8Array([0x03]).buffer,
new Uint8Array([0x55]).buffer,
new Uint8Array([0xAA, 0x55]).buffer,
new Uint8Array([0xFE, 0x01]).buffer,
new Uint8Array([0xFF, 0x01]).buffer,
new Uint8Array([0x00, 0x00, 0x00, 0x01]).buffer
]
initCommands.forEach((cmd, index) => {
setTimeout(() => {
writeBLECharacteristicValue(deviceIdParam, serviceIdParam, writeCharacteristicId, cmd)
}, index * 200)
})
}
const writeBLECharacteristicValue = (deviceIdParam: string, serviceIdParam: string, characteristicIdParam: string, value: ArrayBuffer) => {
uni.writeBLECharacteristicValue({
deviceId: deviceIdParam,
serviceId: serviceIdParam,
characteristicId: characteristicIdParam,
value: value,
success: () => {
console.log('Wrote command:', arrayBufferToHex(value))
},
fail: (err) => {
console.log('Write failed (may be normal for read-only chars):', err.errMsg)
}
})
}
const stringToBuffer = (str: string): ArrayBuffer => {
const encoder = new TextEncoder()
return encoder.encode(str).buffer
}
let readInterval: number | null = null
const setupPeriodicRead = (deviceIdParam: string, serviceIdParam: string, characteristicIdParam: string) => {
if (readInterval) {
clearInterval(readInterval)
}
readInterval = setInterval(() => {
uni.readBLECharacteristicValue({
deviceId: deviceIdParam,
serviceId: serviceIdParam,
characteristicId: characteristicIdParam,
success: (res) => {
handleBLEData(res.value)
},
fail: (err) => {
console.log('Periodic read failed:', err.errMsg)
}
})
}, 300) as unknown as number
console.log('Started periodic read every 300ms')
}
const notifyBLECharacteristicValueChange = (deviceIdParam: string, serviceIdParam: string, characteristicIdParam: string): Promise<void> => {
return new Promise((resolve, reject) => {
uni.notifyBLECharacteristicValueChange({
deviceId: deviceIdParam,
serviceId: serviceIdParam,
characteristicId: characteristicIdParam,
state: true,
success: () => {
console.log('Notify enabled for:', characteristicIdParam)
resolve()
},
fail: (err) => {
console.log('Notify failed, trying read mode:', err.errMsg)
readBLECharacteristicValue(deviceIdParam, serviceIdParam, characteristicIdParam)
.then(() => {
setupPeriodicRead(deviceIdParam, serviceIdParam, characteristicIdParam)
resolve()
})
.catch(reject)
}
})
})
}
const readBLECharacteristicValue = (deviceIdParam: string, serviceIdParam: string, characteristicIdParam: string): Promise<void> => {
return new Promise((resolve, reject) => {
uni.readBLECharacteristicValue({
deviceId: deviceIdParam,
serviceId: serviceIdParam,
characteristicId: characteristicIdParam,
success: (res) => {
console.log('Read characteristic value received')
handleBLEData(res.value)
resolve()
},
fail: (err) => {
console.error('Read failed:', err)
reject(err)
}
})
})
}
const handleBLEData = (value: ArrayBuffer) => {
console.log('=== BLE Data Received ===')
console.log('Data length:', value.byteLength, 'bytes')
console.log('Hex:', arrayBufferToHex(value))
const asciiData = arrayBufferToString(value)
console.log('ASCII:', JSON.stringify(asciiData))
const weight = parseWeightData(value)
console.log('Parsed weight:', weight, 'kg')
if (weight > 0 && weight < 1000 && weightCallback) {
weightCallback(weight)
console.log('Weight sent to callback:', weight)
}
}
export const listenWeightData = (callback: (weight: number) => void) => {
weightCallback = callback
console.log('Weight callback registered')
uni.onBLECharacteristicValueChange((res) => {
console.log('BLE characteristic value changed event')
handleBLEData(res.value)
})
}
const parseWeightData = (buffer: ArrayBuffer): number => {
if (buffer.byteLength === 0) {
console.log('Empty buffer received')
return 0
}
const dataView = new DataView(buffer)
const asciiData = arrayBufferToString(buffer)
const cleaned = asciiData.replace(/[\r\n\s\x00-\x1F]+/g, '')
console.log('Cleaned ASCII:', JSON.stringify(cleaned))
const textPatterns = [
/[+-]?\d+\.\d{1,3}\s*[kK][gG]?/,
/[+-]?\d+\.\d{1,3}/,
/[+-]?\d+\,\d{1,3}/,
/[+-]?\d+\.?\d*/
]
for (const pattern of textPatterns) {
const match = cleaned.match(pattern)
if (match) {
const numStr = match[0].replace(',', '.').replace(/[^0-9.-]/g, '')
const weight = parseFloat(numStr)
if (!isNaN(weight) && weight > 0 && weight < 1000) {
console.log('Parsed from text:', weight)
return weight
}
}
}
if (buffer.byteLength >= 2) {
const testValues: number[] = []
if (buffer.byteLength >= 4) {
testValues.push(dataView.getInt32(0, true) / 1000)
testValues.push(dataView.getInt32(0, false) / 1000)
testValues.push(dataView.getFloat32(0, true))
testValues.push(dataView.getFloat32(0, false))
testValues.push(dataView.getUint32(0, true) / 1000)
testValues.push(dataView.getUint32(0, false) / 1000)
}
if (buffer.byteLength >= 2) {
testValues.push(dataView.getInt16(0, true) / 10)
testValues.push(dataView.getInt16(0, false) / 10)
testValues.push(dataView.getUint16(0, true) / 10)
testValues.push(dataView.getUint16(0, false) / 10)
testValues.push(dataView.getInt16(0, true) / 100)
testValues.push(dataView.getInt16(0, false) / 100)
testValues.push(dataView.getInt16(0, true) / 1000)
testValues.push(dataView.getInt16(0, false) / 1000)
}
if (buffer.byteLength >= 3) {
const byte1 = dataView.getUint8(0)
const byte2 = dataView.getUint8(1)
const byte3 = dataView.getUint8(2)
testValues.push(((byte1 << 16) | (byte2 << 8) | byte3) / 1000)
testValues.push(((byte3 << 16) | (byte2 << 8) | byte1) / 1000)
testValues.push(((byte2 << 16) | (byte1 << 8) | byte3) / 1000)
}
console.log('Testing binary values:', testValues.filter(v => v > 0 && v < 1000))
for (const value of testValues) {
if (value > 0 && value < 1000 && !isNaN(value)) {
console.log('Parsed from binary:', value)
return value
}
}
}
if (buffer.byteLength >= 4) {
const bytes = new Uint8Array(buffer)
let bcdValue = ''
for (let i = 0; i < bytes.length; i++) {
const high = (bytes[i] >> 4) & 0x0F
const low = bytes[i] & 0x0F
if (high >= 0 && high <= 9) bcdValue += high.toString()
if (low >= 0 && low <= 9) bcdValue += low.toString()
}
console.log('BCD value:', bcdValue)
if (bcdValue.length >= 1) {
let weight = parseFloat(bcdValue) / 1000
if (weight > 0 && weight < 1000) {
console.log('Parsed from BCD:', weight)
return weight
}
const dotIndex = bcdValue.length - 3
if (dotIndex > 0) {
const formatted = bcdValue.slice(0, dotIndex) + '.' + bcdValue.slice(dotIndex)
weight = parseFloat(formatted)
if (weight > 0 && weight < 1000) {
console.log('Parsed from BCD with decimal:', weight)
return weight
}
}
}
}
console.log('No valid weight found, returning 0')
return 0
}
const arrayBufferToString = (buffer: ArrayBuffer): string => {
try {
const bytes = new Uint8Array(buffer)
let result = ''
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] >= 32 && bytes[i] <= 126) {
result += String.fromCharCode(bytes[i])
} else if (bytes[i] === 10 || bytes[i] === 13) {
result += bytes[i] === 10 ? '\n' : '\r'
}
}
return result
} catch {
return ''
}
}
const arrayBufferToHex = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer)
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(' ')
}
export const disconnectDevice = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (readInterval) {
clearInterval(readInterval)
readInterval = null
console.log('Stopped periodic read')
}
if (!deviceId) {
resolve()
return
}
uni.closeBLEConnection({
deviceId,
success: () => {
console.log('Disconnected successfully')
deviceId = ''
serviceId = ''
characteristicId = ''
writeCharacteristicId = ''
weightCallback = null
resolve()
},
fail: (err) => {
console.log('Disconnect failed but clearing state:', err.errMsg)
deviceId = ''
serviceId = ''
characteristicId = ''
writeCharacteristicId = ''
weightCallback = null
resolve()
}
})
})
}
+9
View File
@@ -0,0 +1,9 @@
export const formatOrderTime = (dateStr: string): string => {
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
+47
View File
@@ -0,0 +1,47 @@
import type { PriceItem, PriceData } from '@/stores/price'
import { priceApi } from '@/api'
const MOCK_PRICES: PriceData = {
date: new Date().toISOString().split('T')[0],
prices: [
{ category: '001', name: '纸类(书本)', unit: 'kg', price: 1.2, icon: 'paper' },
{ category: '002', name: '纸类(纸板)', unit: 'kg', price: 1.0, icon: 'paper' },
{ category: '003', name: '金属(铜)', unit: 'kg', price: 25.0, icon: 'metal' },
{ category: '004', name: '金属(铁)', unit: 'kg', price: 2.5, icon: 'metal' },
{ category: '005', name: '金属(铝)', unit: 'kg', price: 8.0, icon: 'metal' },
{ category: '006', name: '金属(其他)', unit: 'kg', price: 3.0, icon: 'metal' },
{ category: '007', name: '塑料(瓶子)', unit: 'kg', price: 0.5, icon: 'plastic' },
{ category: '008', name: '塑料(其他)', unit: 'kg', price: 3.0, icon: 'plastic' },
{ category: '009', name: '织物', unit: 'kg', price: 0.8, icon: 'fabric' },
{ category: '010', name: '玻璃', unit: 'kg', price: 0.3, icon: 'glass' }
]
}
export const getTodayPrices = async (): Promise<PriceData | null> => {
try {
return await priceApi.getPrices()
} catch (error) {
console.error('API request failed, using mock data:', error)
return MOCK_PRICES
}
}
export const calculateAmount = (category: string, weightOrCount: number, unitPrice: number): number => {
return Number((weightOrCount * unitPrice).toFixed(2))
}
export const getCategoryIcon = (icon: string): string => {
const icons: Record<string, string> = {
'paper': '/static/icons/paper.png',
'metal': '/static/icons/metal.png',
'plastic': '/static/icons/plastic.png',
'fabric': '/static/icons/fabric.png',
'glass': '/static/icons/glass.png'
}
if (icons[icon]) {
return icons[icon]
}
return '/static/icons/paper.png'
}