Platform upgrade: login auth fix, OCR/OSS upload, system menus and dicts, weather dashboard, weighing and invoice modules

This commit is contained in:
2026-08-14 01:12:33 +08:00
parent c377421a3e
commit 8099ebae0a
75 changed files with 5611 additions and 473 deletions
+7
View File
@@ -14,6 +14,7 @@
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"element-plus": "^2.9.1",
"lunar-javascript": "^1.7.7",
"pinia": "^2.3.0",
"qrcode": "^1.5.4",
"vue": "^3.5.13",
@@ -1874,6 +1875,12 @@
"lodash-es": "*"
}
},
"node_modules/lunar-javascript": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz",
"integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -15,6 +15,7 @@
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"element-plus": "^2.9.1",
"lunar-javascript": "^1.7.7",
"pinia": "^2.3.0",
"qrcode": "^1.5.4",
"vue": "^3.5.13",
+60 -2
View File
@@ -6,7 +6,8 @@ import type {
Product, PurchaseOrder, PurchaseSummaryResult, PaymentSummaryResult,
InvoiceSummaryResult, Overview, TrendPoint, RatioPoint, FarmerTopPoint,
StationPoint, PriceTopPoint, IncomeTopPoint, RealtimeWeighing,
SysUser, UserInfo, WeatherInfo
SysUser, UserInfo, WeatherInfo, PurchaseSummary, PurchaseTransfer, MyAnnouncements,
Announcement, Dict, DictDetail, DictCodeItem, DictItemDto, SysMenu, SysRole, MyPermissions, AboutInfo
} from '@/types'
// ---------- 认证 ----------
@@ -90,6 +91,19 @@ export const getPurchases = (params: PageQuery & {
export const getWeighingList = () => request.get<never, PurchaseOrder[]>('/purchases/weighing')
export const getPurchase = (id: number) => request.get<never, PurchaseOrder>(`/purchases/${id}`)
export const createPurchase = (data: any) => request.post<never, PurchaseOrder>('/purchases', data)
export const createMultiPurchase = (data: {
farmerId: number; productId: number; purchaserOrgId: number; unit: string; notes?: string
weighs: { grade: string; priceType: string; unitPrice: number; grossWeight: number; tareWeight: number; boxCount: number; netWeight?: number }[]
}) => request.post<never, PurchaseOrder>('/purchases/multi', data)
export const getPurchaseSummary = (params?: {
farmerId?: number; productId?: number; orgId?: number; from?: string; to?: string
}) => request.get<never, PurchaseSummary>('/purchases/summary', { params })
export const reprintPurchase = (id: number) =>
request.post<never, { message: string }>(`/purchases/${id}/reprint`)
export const transferPurchase = (id: number, data: { toFarmer: string; toIdCard: string; reason: string }) =>
request.post<never, { message: string; transferNo: string }>(`/purchases/${id}/transfer`, data)
export const getPurchaseTransfers = (id: number) =>
request.get<never, PurchaseTransfer[]>(`/purchases/${id}/transfers`)
export const completeTare = (id: number, tareWeight: number) =>
request.put<never, PurchaseOrder>(`/purchases/${id}/tare`, { tareWeight })
export const updatePurchasePrice = (id: number, unitPrice: number, grade?: string) =>
@@ -99,6 +113,50 @@ export const updatePurchasePrice = (id: number, unitPrice: number, grade?: strin
})
export const cancelPurchase = (id: number) => request.put<never, { message: string }>(`/purchases/${id}/cancel`)
// ---------- 系统管理:数据字典 ----------
export const getDicts = (params: PageQuery & { keyword?: string }) =>
request.get<never, PagedResult<Dict>>('/dicts', { params })
export const getDictDetail = (id: number) => request.get<never, DictDetail>(`/dicts/${id}`)
export const getDictAll = () =>
request.get<never, { code: string; items: DictCodeItem[] }[]>('/dicts/all')
export const saveDict = (data: Partial<Dict> & { id?: number }) =>
data.id ? request.put<never, { message: string }>(`/dicts/${data.id}`, data) : request.post<never, Dict>('/dicts', data)
export const deleteDict = (id: number) => request.delete<never, { message: string }>(`/dicts/${id}`)
export const saveDictItem = (dictId: number, data: Partial<DictItemDto> & { id?: number }) =>
data.id
? request.put<never, { message: string }>(`/dicts/items/${data.id}`, data)
: request.post<never, DictItemDto>(`/dicts/${dictId}/items`, data)
export const deleteDictItem = (id: number) => request.delete<never, { message: string }>(`/dicts/items/${id}`)
// ---------- 系统管理:菜单 / 角色 / 权限 ----------
export const getMenus = () => request.get<never, SysMenu[]>('/menus')
export const getMyPermissions = () => request.get<never, MyPermissions>('/menus/my')
export const saveMenu = (data: Partial<SysMenu> & { id?: number }) =>
data.id ? request.put<never, { message: string }>(`/menus/${data.id}`, data) : request.post<never, { message: string; id: number }>('/menus', data)
export const deleteMenu = (id: number) => request.delete<never, { message: string }>(`/menus/${id}`)
export const getRoles = (params?: PageQuery & { keyword?: string }) =>
request.get<never, PagedResult<SysRole>>('/roles', { params })
export const getRolesAll = () => request.get<never, { id: number; name: string; code: string }[]>('/roles/all')
export const saveRole = (data: Partial<SysRole> & { id?: number }) =>
data.id ? request.put<never, { message: string }>(`/roles/${data.id}`, data) : request.post<never, { message: string; id: number }>('/roles', data)
export const deleteRole = (id: number) => request.delete<never, { message: string }>(`/roles/${id}`)
// ---------- 通知公告 ----------
export const getMyAnnouncements = (params?: PageQuery) =>
request.get<never, MyAnnouncements>('/announcements/my', { params })
export const getAnnouncementUnread = () =>
request.get<never, { count: number }>('/announcements/unread-count')
export const getAnnouncement = (id: number) =>
request.get<never, Announcement>(`/announcements/${id}`)
export const getAnnouncements = (params: PageQuery & { keyword?: string; type?: string }) =>
request.get<never, PagedResult<Announcement>>('/announcements', { params })
export const saveAnnouncement = (data: Partial<Announcement> & { id?: number }) =>
data.id ? request.put<never, { message: string }>(`/announcements/${data.id}`, data) : request.post<never, { message: string; id: number }>('/announcements', data)
export const deleteAnnouncement = (id: number) => request.delete<never, { message: string }>(`/announcements/${id}`)
// ---------- 关于 ----------
export const getAbout = () => request.get<never, AboutInfo>('/about')
// ---------- 支付 ----------
export const getPayments = (params: PageQuery & {
status?: string; farmerId?: number; orgId?: number; from?: string; to?: string
@@ -146,7 +204,7 @@ export const stepBatchProcess = (batchNo: string) =>
request.post<never, InvoiceBatchProgress>(`/invoices/batches/${batchNo}/step`)
// ---------- 报表 ----------
export const getPurchaseSummary = (params: { dimension: string; from?: string; to?: string }) =>
export const getPurchaseSummaryReport = (params: { dimension: string; from?: string; to?: string }) =>
request.get<never, PurchaseSummaryResult>('/reports/purchase-summary', { params })
export const getPaymentSummary = (params?: { from?: string; to?: string }) =>
request.get<never, PaymentSummaryResult>('/reports/payment-summary', { params })
+301
View File
@@ -0,0 +1,301 @@
<template>
<el-popover placement="bottom-end" trigger="click" :width="318" :show-arrow="false" popper-class="hdr-calendar-pop">
<template #reference>
<div class="hdr-icon-btn hdr-cal" title="日历">
<div class="hb">
<span class="hb-top">{{ monthText }}</span>
<span class="hb-day">{{ dayText }}</span>
</div>
</div>
</template>
<div class="lc">
<div class="lc-head">
<span class="lc-today-solar">{{ todaySolar }}</span>
<span class="lc-today-lunar">{{ todayLunar }}</span>
</div>
<div class="lc-toolbar">
<button class="lc-nav" @click="shift(-1)"></button>
<span class="lc-ym">{{ year }}{{ month + 1 }}</span>
<button class="lc-nav" @click="shift(1)"></button>
<button class="lc-back" @click="goToday">今天</button>
</div>
<div class="lc-week">
<span v-for="(w, i) in weekNames" :key="i" :class="{ 'is-we': i >= 5 }">{{ w }}</span>
</div>
<div class="lc-grid">
<div
v-for="(c, i) in cells"
:key="i"
class="lc-cell"
:class="{ dim: c.other, today: c.today, fest: c.fest }"
>
<span class="lc-day">{{ c.day }}</span>
<span class="lc-lunar">{{ c.lunar }}</span>
</div>
</div>
</div>
</el-popover>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Lunar } from 'lunar-javascript'
const now = ref(new Date())
const year = ref(now.value.getFullYear())
const month = ref(now.value.getMonth())
const weekNames = ['一', '二', '三', '四', '五', '六', '日']
// 图标动态日期:每分钟检查一次,跨天自动更新
let dayTimer: number | undefined
onMounted(() => {
dayTimer = window.setInterval(() => {
const t = new Date()
if (
t.getFullYear() !== now.value.getFullYear() ||
t.getMonth() !== now.value.getMonth() ||
t.getDate() !== now.value.getDate()
) {
now.value = t
year.value = t.getFullYear()
month.value = t.getMonth()
}
}, 60_000)
})
onUnmounted(() => {
if (dayTimer) window.clearInterval(dayTimer)
})
const monthText = computed(() => `${now.value.getMonth() + 1}`)
const dayText = computed(() => String(now.value.getDate()).padStart(2, '0'))
interface Cell {
day: number
lunar: string
other: boolean
today: boolean
fest: boolean
}
const todaySolar = computed(() => `${now.value.getFullYear()}${now.value.getMonth() + 1}${now.value.getDate()}`)
const todayLunar = computed(() => {
const l = Lunar.fromDate(now.value)
const f = l.getFestivals()
const o = l.getOtherFestivals()
const j = l.getJieQi()
const extra = f[0] || o[0] || j
return `${l.getYearInGanZhi()}${l.getShengxiao()}${l.getMonthInChinese()}${l.getDayInChinese()}${extra ? ` · ${extra}` : ''}`
})
function lunarText(date: Date): { text: string; fest: boolean } {
const l = Lunar.fromDate(date)
const f = l.getFestivals()
const o = l.getOtherFestivals()
const j = l.getJieQi()
if (f[0]) return { text: f[0], fest: true }
if (o[0]) return { text: o[0], fest: true }
if (j) return { text: j, fest: true }
if (l.getDayInChinese() === '初一') return { text: `${l.getMonthInChinese()}`, fest: false }
return { text: l.getDayInChinese(), fest: false }
}
function isSameDay(a: Date, b: Date) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
const cells = computed<Cell[]>(() => {
const first = new Date(year.value, month.value, 1)
const start = (first.getDay() + 6) % 7
const total = new Date(year.value, month.value + 1, 0).getDate()
const list: Cell[] = []
// 上月补位
for (let i = 0; i < start; i++) {
const d = new Date(year.value, month.value, i - start + 1)
const lt = lunarText(d)
list.push({ day: d.getDate(), lunar: lt.text, other: true, today: false, fest: lt.fest })
}
// 本月
for (let d = 1; d <= total; d++) {
const date = new Date(year.value, month.value, d)
const lt = lunarText(date)
list.push({ day: d, lunar: lt.text, other: false, today: isSameDay(date, now.value), fest: lt.fest })
}
// 下月补位至整行
let idx = 0
while (list.length % 7 !== 0) {
const d = new Date(year.value, month.value + 1, 1 + idx)
const lt = lunarText(d)
list.push({ day: d.getDate(), lunar: lt.text, other: true, today: false, fest: lt.fest })
idx++
}
return list
})
function shift(n: number) {
const d = new Date(year.value, month.value + n, 1)
year.value = d.getFullYear()
month.value = d.getMonth()
}
function goToday() {
year.value = now.value.getFullYear()
month.value = now.value.getMonth()
}
</script>
<style>
.hdr-calendar-pop {
padding: 14px !important;
}
.hdr-calendar-pop .lc-head {
display: flex;
flex-direction: column;
gap: 2px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 10px;
}
.hdr-calendar-pop .lc-today-solar {
font-size: 15px;
font-weight: 700;
color: #1f2d3d;
}
.hdr-calendar-pop .lc-today-lunar {
font-size: 12px;
color: #1a73e8;
}
.hdr-calendar-pop .lc-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.hdr-calendar-pop .lc-nav {
width: 26px;
height: 26px;
border: none;
background: #f0f5ff;
color: #1a73e8;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.hdr-calendar-pop .lc-nav:hover {
background: #dbe7ff;
}
.hdr-calendar-pop .lc-ym {
flex: 1;
text-align: center;
font-size: 14px;
font-weight: 600;
color: #1f2d3d;
}
.hdr-calendar-pop .lc-back {
height: 26px;
padding: 0 10px;
border: 1px solid #d9e2f5;
background: #fff;
color: #1a73e8;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.hdr-calendar-pop .lc-back:hover {
background: #f0f5ff;
}
.hdr-calendar-pop .lc-week {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 4px;
}
.hdr-calendar-pop .lc-week span {
text-align: center;
font-size: 12px;
color: #7a8699;
padding: 4px 0;
}
.hdr-calendar-pop .lc-week span.is-we {
color: #e5654c;
}
.hdr-calendar-pop .lc-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.hdr-calendar-pop .lc-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 44px;
border-radius: 8px;
cursor: default;
}
.hdr-calendar-pop .lc-day {
font-size: 13px;
color: #333;
line-height: 1.3;
}
.hdr-calendar-pop .lc-lunar {
font-size: 10px;
color: #a3aab5;
line-height: 1.3;
transform: scale(0.9);
}
.hdr-calendar-pop .lc-cell.dim {
opacity: 0.4;
}
.hdr-calendar-pop .lc-cell.today {
background: #1a73e8;
}
.hdr-calendar-pop .lc-cell.today .lc-day {
color: #fff;
font-weight: 700;
}
.hdr-calendar-pop .lc-cell.today .lc-lunar {
color: #dce7ff;
}
.hdr-calendar-pop .lc-cell.fest .lc-day,
.hdr-calendar-pop .lc-cell.fest .lc-lunar {
color: #e5654c;
}
/* 顶栏动态日历图标 */
.hdr-cal {
padding: 0;
}
.hdr-cal .hb {
width: 34px;
height: 34px;
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
background: #fff;
}
.hdr-cal .hb-top {
height: 12px;
background: linear-gradient(135deg, #ff5f57, #e63946);
color: #fff;
font-size: 9px;
line-height: 12px;
text-align: center;
font-weight: 700;
letter-spacing: 0.5px;
}
.hdr-cal .hb-day {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 800;
color: #2d3436;
line-height: 1;
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<el-popover placement="bottom-end" trigger="click" :width="330" :show-arrow="false" popper-class="hdr-msg-pop">
<template #reference>
<div class="hdr-icon-btn" title="消息通知">
<el-icon :size="20"><Bell /></el-icon>
<span v-if="unread > 0" class="hdr-badge">{{ unread > 99 ? '99+' : unread }}</span>
</div>
</template>
<div class="hm">
<div class="hm-head">
<span>通知公告{{ unread }} 条未读</span>
<el-link v-if="unread > 0" type="primary" underline="never" @click="goAll">全部已读</el-link>
</div>
<div class="hm-list">
<div v-for="m in messages" :key="m.id" class="hm-item" :class="{ unread: !m.read }" @click="markRead(m)">
<span class="hm-tag" :class="tagClass(m.type)">{{ tagText(m.type) }}</span>
<div class="hm-body">
<div class="hm-title">{{ m.title }}</div>
<div class="hm-time">{{ m.time }}</div>
</div>
</div>
</div>
<div v-if="messages.length === 0" class="hm-empty">暂无公告</div>
<div v-if="total > messages.length" class="hm-more" @click="goAll">查看全部公告</div>
</div>
</el-popover>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getMyAnnouncements, getAnnouncement } from '@/api'
interface Msg {
id: number
title: string
time: string
read: boolean
type: string
}
const router = useRouter()
const messages = ref<Msg[]>([])
const unread = ref(0)
const total = ref(0)
const tagText = (t: string) => (t === 'announce' ? '公告' : t === 'price' ? '行情' : t === 'policy' ? '政策' : '通知')
const tagClass = (t: string) => (t === 'announce' ? 'order' : t === 'price' ? 'pay' : 'system')
const load = async () => {
try {
const res = await getMyAnnouncements({ page: 1, pageSize: 6 })
messages.value = res.items.map((a) => ({
id: a.id,
title: a.title,
time: new Date(a.publishedAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
read: a.isRead,
type: a.type
}))
unread.value = res.unread
total.value = res.total
} catch {
/* 后端不可用时忽略 */
}
}
async function markRead(m: Msg) {
if (!m.read) {
try {
await getAnnouncement(m.id)
m.read = true
unread.value = Math.max(0, unread.value - 1)
} catch { /* 忽略 */ }
}
}
function goAll() {
router.push('/notify/announcements')
}
onMounted(load)
</script>
<style scoped>
.hdr-icon-btn {
position: relative;
}
.hdr-badge {
position: absolute;
top: 2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #f56c6c;
color: #fff;
font-size: 11px;
line-height: 16px;
text-align: center;
box-sizing: border-box;
}
</style>
<style>
.hdr-msg-pop {
padding: 12px !important;
}
.hdr-msg-pop .hm-head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
color: #1f2d3d;
}
.hdr-msg-pop .hm-list {
max-height: 300px;
overflow-y: auto;
}
.hdr-msg-pop .hm-item {
display: flex;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid #f5f6f8;
cursor: pointer;
}
.hdr-msg-pop .hm-item:hover {
background: #fafcff;
}
.hdr-msg-pop .hm-tag {
flex: none;
align-self: flex-start;
font-size: 11px;
line-height: 1;
padding: 3px 6px;
border-radius: 4px;
margin-top: 2px;
}
.hdr-msg-pop .hm-tag.order {
background: #eaf2ff;
color: #1a73e8;
}
.hdr-msg-pop .hm-tag.pay {
background: #e6f7ef;
color: #34a853;
}
.hdr-msg-pop .hm-tag.system {
background: #f5f5f5;
color: #7a8699;
}
.hdr-msg-pop .hm-body {
flex: 1;
min-width: 0;
}
.hdr-msg-pop .hm-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #333;
line-height: 1.5;
}
.hdr-msg-pop .hm-item.unread .hm-title {
font-weight: 600;
}
.hdr-msg-pop .hm-item.unread .hm-title::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: #1a73e8;
flex: none;
}
.hdr-msg-pop .hm-time {
font-size: 12px;
color: #a3aab5;
margin-top: 4px;
}
.hdr-msg-pop .hm-empty {
padding: 24px 0;
text-align: center;
color: #a3aab5;
font-size: 13px;
}
.hdr-msg-pop .hm-more {
padding-top: 10px;
text-align: center;
color: #1a73e8;
font-size: 13px;
cursor: pointer;
}
.hdr-msg-pop .hm-more:hover {
text-decoration: underline;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<el-popover placement="bottom-end" trigger="hover" :width="330" :show-arrow="false" popper-class="hdr-weather-pop">
<template #reference>
<div class="hdr-weather" :class="{ 'is-loading': !loaded }" title="天气预报">
<el-icon :size="24" class="hdr-weather-icon"><component :is="currentIcon" /></el-icon>
<div class="hdr-weather-meta">
<div class="hdr-weather-temp">{{ loaded ? `${current.temperature}°C` : '--°C' }}</div>
<div class="hdr-weather-text">{{ loaded ? `${current.text} · ${city}` : '加载中' }}</div>
</div>
</div>
</template>
<div v-if="loaded" class="wd">
<div class="wd-head">
<span class="wd-city">{{ city }}</span>
<span class="wd-now">{{ current.text }} {{ current.temperature }}°C · 体感 {{ current.feels }}°C</span>
</div>
<div class="wd-list">
<div v-for="d in daily" :key="d.date" class="wd-item">
<span class="wd-week">{{ d.week }}</span>
<el-icon :size="18" class="wd-icon"><component :is="d.icon" /></el-icon>
<span class="wd-temp">{{ d.max }}° / {{ d.min }}°</span>
</div>
</div>
<div class="wd-foot">数据来源 Open-Meteo</div>
</div>
<div v-else class="wd-error">
<el-icon :size="20"><Cloudy /></el-icon>
<span>天气服务暂时不可用</span>
</div>
</el-popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Cloudy } from '@element-plus/icons-vue'
import { useWeather } from '@/composables/useWeather'
const weather = useWeather()
const loaded = computed(() => weather.loaded)
const city = computed(() => weather.city)
const current = computed(() => weather.current)
const daily = computed(() => weather.daily)
const currentIcon = computed(() => weather.current.icon)
</script>
<style scoped>
.hdr-weather {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
color: #4a5568;
transition: background 0.2s;
}
.hdr-weather:hover {
background: #f0f5ff;
}
.hdr-weather-icon {
color: #1a73e8;
}
.hdr-weather-temp {
font-size: 16px;
font-weight: 700;
color: #1f2d3d;
line-height: 1.2;
}
.hdr-weather-text {
font-size: 12px;
color: #7a8699;
line-height: 1.2;
}
.hdr-weather.is-loading {
opacity: 0.7;
}
</style>
<style>
.hdr-weather-pop {
padding: 12px !important;
}
.hdr-weather-pop .wd-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.hdr-weather-pop .wd-city {
font-size: 16px;
font-weight: 700;
color: #1f2d3d;
}
.hdr-weather-pop .wd-now {
font-size: 13px;
color: #4a5568;
}
.hdr-weather-pop .wd-list {
padding-top: 6px;
}
.hdr-weather-pop .wd-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 4px;
font-size: 13px;
color: #4a5568;
}
.hdr-weather-pop .wd-week {
width: 34px;
color: #1f2d3d;
}
.hdr-weather-pop .wd-icon {
color: #1a73e8;
}
.hdr-weather-pop .wd-temp {
margin-left: auto;
}
.hdr-weather-pop .wd-foot {
margin-top: 6px;
font-size: 12px;
color: #b0b8c4;
text-align: right;
}
.hdr-weather-pop .wd-error {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px 0;
color: #7a8699;
font-size: 13px;
}
</style>
+152
View File
@@ -0,0 +1,152 @@
<template>
<el-dialog v-model="visible" :title="`票据打印(${formatLabel}`" width="720px" destroy-on-close
:close-on-click-modal="false" append-to-body>
<div class="rp-toolbar">
<el-select v-model="format" size="small" style="width: 220px" @change="onFormatChange">
<el-option v-for="f in formats" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
<el-button size="small" type="primary" @click="doPrint">打印</el-button>
<span class="rp-tip">{{ formatTip }}</span>
</div>
<div class="rp-sheet" :class="format">
<div class="rp-sheet-inner">
<h2 class="rp-title">{{ order?.purchaserOrgName || '农易富农产品收购交易平台' }}</h2>
<div class="rp-no">收购单号{{ order?.orderNo }}</div>
<div class="rp-time">{{ printTime }}</div>
<table class="rp-base">
<tr><td>农户</td><td>{{ order?.farmerName }}</td></tr>
<tr><td>品种</td><td>{{ order?.productName }}</td></tr>
<tr><td>单位</td><td>{{ order?.unit }}</td></tr>
<tr><td>操作员</td><td>{{ order?.operatorName }}</td></tr>
</table>
<table class="rp-items" v-if="order && order.weighs && order.weighs.length">
<thead><tr><th>等级</th><th>单价</th><th>毛重</th><th>皮重</th><th>框数</th><th>净重</th><th>金额</th></tr></thead>
<tbody>
<tr v-for="w in order.weighs" :key="w.id">
<td>{{ w.grade }}</td><td>{{ fmtPrice(w.unitPrice) }}</td><td>{{ w.grossWeight }}</td>
<td>{{ w.tareWeight }}</td><td>{{ w.boxCount }}</td><td>{{ w.netWeight }}</td><td>{{ fmtMoney(w.amount) }}</td>
</tr>
</tbody>
</table>
<table class="rp-grade" v-if="gradeSummary.length">
<thead><tr><th>等级</th><th>净重合计</th><th>金额合计</th></tr></thead>
<tbody>
<tr v-for="g in gradeSummary" :key="g.grade"><td>{{ g.grade }}</td><td>{{ g.net }}</td><td>{{ fmtMoney(g.amount) }}</td></tr>
</tbody>
</table>
<table class="rp-total">
<tr><td>合计净重</td><td>{{ order?.netWeight }} {{ order?.unit }}</td></tr>
<tr><td>合计框数</td><td>{{ order?.boxCount }} </td></tr>
<tr><td>合计金额</td><td>¥ {{ fmtMoney(order?.amount) }}</td></tr>
<tr><td>均价</td><td>¥ {{ avgPrice }} /{{ order?.unit }}</td></tr>
</table>
<div class="rp-sign">客户签字__________ 经手人{{ order?.operatorName }}</div>
<div class="rp-footer">农易富 · 版权所有 bbitcn.com</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PurchaseOrder, DictCodeItem } from '@/types'
const props = defineProps<{ modelValue: boolean; order?: PurchaseOrder | null; formats?: DictCodeItem[] }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
const visible = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) })
const format = ref('thermal_58')
const formats = computed<DictCodeItem[]>(() => {
if (props.formats && props.formats.length) return props.formats
return [
{ label: '58mm 热敏小票', value: 'thermal_58', ext: '58mm', isDefault: true },
{ label: '80mm 热敏小票', value: 'thermal_80', ext: '80mm', isDefault: false },
{ label: '三等分针式票据', value: 'dotmatrix_3part', ext: '241mm x 139.7mm/联', isDefault: false },
{ label: 'A5 激光打印', value: 'laser_a5', ext: 'A5', isDefault: false }
]
})
const formatLabel = computed(() => formats.value.find((f) => f.value === format.value)?.label || format.value)
const formatTip = computed(() => ({
thermal_58: '58mm 热敏小票:连续出纸,热敏打印机',
thermal_80: '80mm 热敏小票:收银小票打印机',
dotmatrix_3part: '241 x 139.7mm/联 带孔链式纸,针式打印机',
laser_a5: 'A5 148 x 210mm,激光/喷墨打印机'
}[format.value] || ''))
const printTime = new Date().toLocaleString('zh-CN', { hour12: false })
const avgPrice = computed(() => {
const o = props.order
return o && o.netWeight > 0 ? (o.amount / o.netWeight).toFixed(2) : '0.00'
})
const gradeSummary = computed(() => {
const o = props.order
if (!o || !o.weighs || !o.weighs.length) return [] as { grade: string; net: number; amount: number }[]
const map = new Map<string, { grade: string; net: number; amount: number }>()
for (const w of o.weighs) {
const k = w.grade || '未分级'
const cur = map.get(k) ?? { grade: k, net: 0, amount: 0 }
cur.net += w.netWeight
cur.amount += w.amount
map.set(k, cur)
}
return [...map.values()]
})
const fmtPrice = (v?: number) => (v ?? 0).toFixed(2)
const fmtMoney = (v?: number) => (v ?? 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const onFormatChange = (v: string) => { format.value = v }
function doPrint() {
const w = window.open('', '_blank', 'width=900,height=700')
if (!w) return
const sheet = document.querySelector<HTMLElement>('.rp-sheet')
if (!sheet) return
w.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>票据打印</title><style>${buildPrintCss()}</style></head><body>${sheet.outerHTML}</body></html>`)
w.document.close()
w.focus()
setTimeout(() => w.print(), 300)
}
function buildPrintCss(): string {
const sizes: Record<string, { w: string; fontSize: string }> = {
thermal_58: { w: '58mm', fontSize: '12px' },
thermal_80: { w: '80mm', fontSize: '13px' },
dotmatrix_3part: { w: '241mm', fontSize: '14px' },
laser_a5: { w: '148mm', fontSize: '13px' }
}
const s = sizes[format.value] || sizes.thermal_58
return `*{margin:0;padding:0;box-sizing:border-box}
body{width:${s.w};font-size:${s.fontSize};font-family:'宋体',SimSun,monospace;color:#000}
.rp-sheet-inner{padding:4mm}
.rp-title{text-align:center;font-size:1.5em;margin-bottom:2mm;letter-spacing:1px}
.rp-no{text-align:center;font-weight:bold}
.rp-time{text-align:center;margin-bottom:2mm}
table{width:100%;border-collapse:collapse;margin:2mm 0}
.rp-base td{padding:1mm 0}
.rp-base td:first-child{width:18mm;font-weight:bold}
.rp-items th,.rp-items td,.rp-grade th,.rp-grade td,.rp-total td{border:1px solid #000;padding:1mm 1.5mm;text-align:center}
.rp-total td:first-child{font-weight:bold}
.rp-sign{margin-top:3mm}
.rp-footer{margin-top:2mm;text-align:center;font-size:0.9em}`
}
</script>
<style scoped>
.rp-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.rp-tip { font-size: 12px; color: #909399; }
.rp-sheet { border: 1px dashed #ccc; background: #fff; margin: 0 auto; overflow-x: auto; }
.rp-sheet.thermal_58 { width: 58mm; }
.rp-sheet.thermal_80 { width: 80mm; }
.rp-sheet.dotmatrix_3part { width: 241mm; }
.rp-sheet.laser_a5 { width: 148mm; }
.rp-sheet-inner { padding: 8px; font-family: '宋体', SimSun, monospace; color: #000; }
.rp-title { text-align: center; font-size: 18px; margin-bottom: 6px; }
.rp-no { text-align: center; font-weight: 600; }
.rp-time { text-align: center; color: #666; font-size: 12px; margin-bottom: 8px; }
.rp-sheet table { width: 100%; border-collapse: collapse; margin: 6px 0; }
.rp-base td { padding: 2px 0; }
.rp-base td:first-child { width: 60px; font-weight: 600; }
.rp-items th, .rp-items td, .rp-grade th, .rp-grade td, .rp-total td { border: 1px solid #333; padding: 3px 4px; text-align: center; }
.rp-total td:first-child { font-weight: 600; }
.rp-sign { margin-top: 10px; }
.rp-footer { margin-top: 6px; text-align: center; font-size: 11px; color: #666; }
</style>
+149
View File
@@ -0,0 +1,149 @@
import { ref, computed, markRaw, reactive } from 'vue'
import type { Component } from 'vue'
import { Sunny, PartlyCloudy, Cloudy, MostlyCloudy, Umbrella, Lightning } from '@element-plus/icons-vue'
/** Open-Meteo weather_code -> 中文描述 + 图标(与顶部 banner 一致的唯一数据源) */
export const WEATHER: Record<number, { text: string; icon: Component }> = {
0: { text: '晴', icon: Sunny },
1: { text: '大致晴朗', icon: Sunny },
2: { text: '多云', icon: PartlyCloudy },
3: { text: '阴', icon: Cloudy },
45: { text: '雾', icon: MostlyCloudy },
48: { text: '雾凇', icon: MostlyCloudy },
51: { text: '毛毛雨', icon: Umbrella },
53: { text: '毛毛雨', icon: Umbrella },
55: { text: '毛毛雨', icon: Umbrella },
56: { text: '冻毛毛雨', icon: Umbrella },
57: { text: '冻毛毛雨', icon: Umbrella },
61: { text: '小雨', icon: Umbrella },
63: { text: '中雨', icon: Umbrella },
65: { text: '大雨', icon: Umbrella },
66: { text: '冻雨', icon: Umbrella },
67: { text: '冻雨', icon: Umbrella },
71: { text: '小雪', icon: Cloudy },
73: { text: '中雪', icon: Cloudy },
75: { text: '大雪', icon: Cloudy },
77: { text: '雪粒', icon: Cloudy },
80: { text: '阵雨', icon: Umbrella },
81: { text: '强阵雨', icon: Umbrella },
82: { text: '暴雨', icon: Lightning },
85: { text: '阵雪', icon: Cloudy },
86: { text: '强阵雪', icon: Cloudy },
95: { text: '雷阵雨', icon: Lightning },
96: { text: '雷雨伴冰雹', icon: Lightning },
99: { text: '雷雨伴冰雹', icon: Lightning },
}
export interface WeatherForecast {
date: string
week: string
min: number
max: number
icon: Component
text: string
}
export interface WeatherState {
loaded: boolean
city: string
region: string
current: { temperature: number; feels: number; text: string; icon: Component }
daily: WeatherForecast[]
todayMin: number
todayMax: number
wind: string
refresh: () => Promise<void>
}
async function fetchJson(url: string, timeout = 6000) {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeout)
try {
const res = await fetch(url, { signal: ctrl.signal })
if (!res.ok) throw new Error(String(res.status))
return await res.json()
} finally {
clearTimeout(timer)
}
}
const DEFAULT_LOC = { lat: 30.67, lon: 104.07, city: '成都', region: '四川省' }
async function locate() {
try {
const d = await fetchJson('http://ip-api.com/json/?lang=zh-CN')
if (d && d.status === 'success' && d.lat != null && d.lon != null && d.city) {
return { lat: d.lat, lon: d.lon, city: d.city, region: d.regionName || '' }
}
} catch {
/* 使用默认 */
}
return DEFAULT_LOC
}
function createWeatherState(): WeatherState {
const loaded = ref(false)
const city = ref('')
const region = ref('')
const current = ref({ temperature: 0, feels: 0, text: '未知', icon: markRaw(Sunny) as Component })
const daily = ref<WeatherForecast[]>([])
const wind = ref('')
const todayMin = computed(() => daily.value[0]?.min ?? 0)
const todayMax = computed(() => daily.value[0]?.max ?? 0)
async function refresh() {
const loc = await locate()
city.value = loc.city
region.value = loc.region
try {
const data = await fetchJson(
`https://api.open-meteo.com/v1/forecast?latitude=${loc.lat}&longitude=${loc.lon}` +
`&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m` +
`&daily=weather_code,temperature_2m_max,temperature_2m_min` +
`&timezone=auto&forecast_days=5`
)
const cur = data.current
const m = WEATHER[cur.weather_code] || { text: '未知', icon: Cloudy }
current.value = {
temperature: Math.round(cur.temperature_2m),
feels: Math.round(cur.apparent_temperature),
text: m.text,
icon: markRaw(m.icon),
}
const dirs = ['北', '东北', '东', '东南', '南', '西南', '西', '西北']
const dir = dirs[Math.round((cur.wind_direction_10m ?? 0) / 45) % 8]
const kmh = Math.round(cur.wind_speed_10m ?? 0)
wind.value = kmh > 0 ? `${dir}${kmh}km/h` : ''
daily.value = data.daily.time.map((date: string, i: number) => {
const [y, mo, dd] = date.split('-').map(Number)
const week = '日一二三四五六'[new Date(y, mo - 1, dd).getDay()]
const dm = WEATHER[data.daily.weather_code[i]] || { text: '未知', icon: Cloudy }
return {
date,
week: `${week}`,
min: Math.round(data.daily.temperature_2m_min[i]),
max: Math.round(data.daily.temperature_2m_max[i]),
icon: markRaw(dm.icon),
text: dm.text,
}
})
loaded.value = true
} catch {
// 天气服务不可用,保持占位状态
}
}
// 用 reactive 包装,确保使用方访问属性时自动解包 ref
return reactive({ loaded, city, region, current, daily, todayMin, todayMax, wind, refresh })
}
/** 全局共享天气状态:顶部 banner / 首页 / 可视化大屏 使用同一份数据 */
let shared: WeatherState | null = null
export function useWeather(): WeatherState {
if (!shared) {
shared = createWeatherState()
shared.refresh()
}
return shared
}
+16
View File
@@ -0,0 +1,16 @@
import type { Directive, DirectiveBinding } from 'vue'
import { useAuthStore } from '@/stores/auth'
/**
* 功能级权限指令:v-permission="'purchase:create'"
* 无权限时移除元素。
*/
export const permission: Directive<HTMLElement, string | string[]> = {
mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
const auth = useAuthStore()
const perms = Array.isArray(binding.value) ? binding.value : [binding.value]
if (perms.length === 0) return
const ok = perms.some((p) => auth.hasPermission(p))
if (!ok) el.remove()
}
}
+100 -45
View File
@@ -2,52 +2,35 @@
<el-container class="layout">
<el-aside width="220px" class="aside">
<div class="logo">
<el-icon :size="26" color="#3fae60"><Apple /></el-icon>
<el-icon :size="26" color="#1a73e8"><component :is="logoIcon" /></el-icon>
<div>
<div class="logo-title">农易富</div>
<div class="logo-title">农易富<sup class="reg">®</sup></div>
<div class="logo-sub">农产品收购交易平台</div>
</div>
</div>
<el-menu
:default-active="activeMenu"
router
background-color="#0f2b1d"
text-color="#cfe3d6"
background-color="#001529"
text-color="rgba(255,255,255,0.73)"
active-text-color="#ffffff"
class="menu"
>
<el-menu-item index="/home">
<el-icon><HomeFilled /></el-icon><span>工作台</span>
</el-menu-item>
<el-sub-menu index="biz">
<template #title>
<el-icon><Van /></el-icon><span>收购业务</span>
</template>
<el-menu-item index="/weighing"><el-icon><Odometer /></el-icon>过磅称重</el-menu-item>
<el-menu-item index="/weighing/touch"><el-icon><Iphone /></el-icon>触摸屏过磅</el-menu-item>
<el-menu-item index="/payments"><el-icon><Wallet /></el-icon>电子支付</el-menu-item>
<el-menu-item index="/invoices"><el-icon><Tickets /></el-icon>反向开票</el-menu-item>
</el-sub-menu>
<el-sub-menu index="base">
<template #title>
<el-icon><FolderOpened /></el-icon><span>基础资料</span>
</template>
<el-menu-item index="/farmers"><el-icon><User /></el-icon>农户管理</el-menu-item>
<el-menu-item index="/products"><el-icon><Goods /></el-icon>品种管理</el-menu-item>
<el-menu-item index="/orgs"><el-icon><OfficeBuilding /></el-icon>组织管理</el-menu-item>
<el-menu-item index="/users"><el-icon><Avatar /></el-icon>用户管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="report">
<template #title>
<el-icon><DataAnalysis /></el-icon><span>统计报表</span>
</template>
<el-menu-item index="/reports/purchase"><el-icon><TrendCharts /></el-icon>收购报表</el-menu-item>
<el-menu-item index="/reports/payment"><el-icon><Money /></el-icon>付款报表</el-menu-item>
<el-menu-item index="/reports/invoice"><el-icon><Document /></el-icon>开票报表</el-menu-item>
</el-sub-menu>
<template v-for="item in menuTree" :key="item.id">
<!-- 目录 -->
<el-sub-menu v-if="item.type === 'directory' && visibleChildren(item).length" :index="`dir-${item.id}`">
<template #title>
<el-icon><component :is="item.icon || 'FolderOpened'" /></el-icon><span>{{ item.name }}</span>
</template>
<el-menu-item v-for="child in visibleChildren(item)" :key="child.id" :index="child.path">
<el-icon><component :is="child.icon || 'Document'" /></el-icon><span>{{ child.name }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 菜单 -->
<el-menu-item v-else-if="item.type === 'menu' && item.visible" :index="item.path">
<el-icon><component :is="item.icon || 'Document'" /></el-icon><span>{{ item.name }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
@@ -55,9 +38,15 @@
<el-header class="header">
<div class="page-title">{{ route.meta.title || '农易富' }}</div>
<div class="header-right">
<el-button type="success" plain @click="openDashboard">
<el-icon><Monitor /></el-icon>&nbsp;可视化大屏
</el-button>
<HeaderWeather />
<HeaderCalendar />
<HeaderMessage />
<el-divider direction="vertical" />
<el-tooltip content="可视化大屏" placement="bottom">
<div class="hdr-icon-btn" @click="openDashboard">
<el-icon :size="20"><Monitor /></el-icon>
</div>
</el-tooltip>
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-avatar :size="32" class="avatar">{{ (auth.user?.realName || '用')[0] }}</el-avatar>
@@ -68,6 +57,7 @@
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="demo" divided>创建演示数据</el-dropdown-item>
<el-dropdown-item command="about">关于系统</el-dropdown-item>
<el-dropdown-item command="password" divided>修改密码</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
@@ -79,6 +69,7 @@
<el-main class="main">
<router-view />
</el-main>
<el-footer class="footer" height="32px">版权所有 © 2026 bbitcn.com 农易富® 农产品收购交易平台</el-footer>
</el-container>
</el-container>
@@ -102,11 +93,14 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { changePassword, createDemoData } from '@/api'
import HeaderWeather from '@/components/HeaderWeather.vue'
import HeaderCalendar from '@/components/HeaderCalendar.vue'
import HeaderMessage from '@/components/HeaderMessage.vue'
const route = useRoute()
const router = useRouter()
@@ -114,6 +108,60 @@ const auth = useAuthStore()
const activeMenu = computed(() => route.path)
/** 系统图标(数据字典 system_logo 配置,可选项为农产品图标) */
const logoIcon = ref<string>('Apple')
onMounted(async () => {
try {
const all = await getDictAll()
const logoDict = all.find((d) => d.code === 'system_logo')
const item = logoDict?.items?.find((i) => i.isDefault) || logoDict?.items?.[0]
if (item && item.value) logoIcon.value = item.value
} catch {
/* 保留默认图标 */
}
})
/** 权限菜单树(后端按角色下发) */
const menuTree = computed(() => {
const menus = auth.menus
if (!menus || menus.length === 0) {
// 兜底:未加载权限时显示常用菜单,保证低版本账号可用
return [
{ id: 1, name: '首页', path: '/home', type: 'menu', icon: 'HomeFilled', visible: true, children: [] },
{
id: 2, name: '收购业务', type: 'directory', icon: 'Van', visible: true,
children: [
{ id: 21, name: '过磅称重', path: '/weighing', type: 'menu', icon: 'Odometer', visible: true, children: [] },
{ id: 22, name: '触摸屏过磅', path: '/weighing/touch', type: 'menu', icon: 'Iphone', visible: true, children: [] },
{ id: 23, name: '电子支付', path: '/payments', type: 'menu', icon: 'Wallet', visible: true, children: [] },
{ id: 24, name: '反向开票', path: '/invoices', type: 'menu', icon: 'Tickets', visible: true, children: [] }
]
},
{
id: 3, name: '基础资料', type: 'directory', icon: 'FolderOpened', visible: true,
children: [
{ id: 31, name: '农户管理', path: '/farmers', type: 'menu', icon: 'User', visible: true, children: [] },
{ id: 32, name: '品种管理', path: '/products', type: 'menu', icon: 'Goods', visible: true, children: [] },
{ id: 33, name: '组织管理', path: '/orgs', type: 'menu', icon: 'OfficeBuilding', visible: true, children: [] },
{ id: 34, name: '用户管理', path: '/users', type: 'menu', icon: 'Avatar', visible: true, children: [] }
]
},
{
id: 4, name: '统计报表', type: 'directory', icon: 'DataAnalysis', visible: true,
children: [
{ id: 41, name: '收购报表', path: '/reports/purchase', type: 'menu', icon: 'TrendCharts', visible: true, children: [] },
{ id: 42, name: '付款报表', path: '/reports/payment', type: 'menu', icon: 'Money', visible: true, children: [] },
{ id: 43, name: '开票报表', path: '/reports/invoice', type: 'menu', icon: 'Document', visible: true, children: [] }
]
}
] as any[]
}
return menus
})
/** 过滤目录下可见子菜单 */
const visibleChildren = (item: any) => (item.children ?? []).filter((c: any) => c.visible !== false)
const pwdVisible = ref(false)
const pwdLoading = ref(false)
const pwdForm = ref({ oldPassword: '', newPassword: '', confirm: '' })
@@ -125,6 +173,7 @@ const openDashboard = () => {
const handleCommand = (cmd: string) => {
if (cmd === 'profile') router.push('/profile')
else if (cmd === 'about') router.push('/about')
else if (cmd === 'demo') {
ElMessageBox.confirm(
'将创建当日 20-60 条随机测试收购单(品种:蚕茧,等级 95% 为正茧),是否继续?',
@@ -177,18 +226,24 @@ const submitPwd = async () => {
<style scoped>
.layout { height: 100%; }
.aside { background: #0f2b1d; display: flex; flex-direction: column; }
.aside { background: #001529; display: flex; flex-direction: column; }
.logo { display: flex; align-items: center; gap: 10px; padding: 16px 18px; color: #fff; }
.logo-title { font-size: 20px; font-weight: 700; letter-spacing: 2px; }
.logo-sub { font-size: 11px; color: #a8c8b4; }
.reg { font-size: 12px; font-weight: 400; color: rgba(255,255,255,0.8); margin-left: 2px; }
.logo-sub { font-size: 11px; color: rgba(255,255,255,0.55); }
.menu { border-right: none; flex: 1; overflow-y: auto; }
.menu :deep(.el-menu-item.is-active) { background: #1a73e8; }
.menu :deep(.el-sub-menu__title:hover), .menu :deep(.el-menu-item:hover) { background: rgba(26,115,232,0.2); }
.header {
display: flex; align-items: center; justify-content: space-between;
background: #fff; border-bottom: 1px solid #e8e8e8;
}
.page-title { font-size: 17px; font-weight: 600; color: #1f2d3d; }
.header-right { display: flex; align-items: center; gap: 14px; }
.header-right { display: flex; align-items: center; gap: 6px; }
.header-right :deep(.el-divider--vertical) { margin: 0 6px; }
.user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; color: #333; outline: none; }
.avatar { background: #3fae60; color: #fff; font-weight: 600; }
.main { background: #f5f7fa; padding: 16px; overflow-y: auto; }
.avatar { background: #1a73e8; color: #fff; font-weight: 600; }
.main { background: #f0f2f5; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; }
.main > :deep(*) { flex: 1; }
.footer { display: flex; align-items: center; justify-content: center; font-size: 12px; color: #999; background: #fff; border-top: 1px solid #e8e8e8; }
</style>
+3
View File
@@ -3,16 +3,19 @@ import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import './styles/theme.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { permission } from './directives/permission'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.directive('permission', permission)
// 全局注册图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+25 -7
View File
@@ -7,19 +7,27 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/home',
children: [
{ path: 'home', name: 'Home', component: () => import('@/views/Home.vue'), meta: { title: '工作台' } },
{ path: 'farmers', name: 'Farmers', component: () => import('@/views/farmers/FarmerList.vue'), meta: { title: '农户管理' } },
{ path: 'home', name: 'Home', component: () => import('@/views/Home.vue'), meta: { title: '工作台', perm: 'home:view' } },
{ path: 'farmers', name: 'Farmers', component: () => import('@/views/farmers/FarmerList.vue'), meta: { title: '农户管理', perm: 'farmer:list' } },
{ path: 'products', name: 'Products', component: () => import('@/views/products/ProductList.vue'), meta: { title: '品种管理' } },
{ path: 'orgs', name: 'Orgs', component: () => import('@/views/orgs/OrgList.vue'), meta: { title: '组织管理' } },
{ path: 'users', name: 'Users', component: () => import('@/views/users/UserList.vue'), meta: { title: '用户管理' } },
{ path: 'weighing', name: 'Weighing', component: () => import('@/views/weighing/WeighingList.vue'), meta: { title: '过磅称重' } },
{ path: 'weighing/touch', name: 'WeighingTouch', component: () => import('@/views/weighing/WeighingTouch.vue'), meta: { title: '触摸屏过磅' } },
{ path: 'orgs', name: 'Orgs', component: () => import('@/views/orgs/OrgList.vue'), meta: { title: '组织管理', perm: 'system:org:list' } },
{ path: 'users', name: 'Users', component: () => import('@/views/users/UserList.vue'), meta: { title: '用户管理', perm: 'system:user:list' } },
{ path: 'weighing', name: 'Weighing', component: () => import('@/views/weighing/WeighingList.vue'), meta: { title: '过磅称重', perm: 'weighing:list' } },
{ path: 'weighing/touch', name: 'WeighingTouch', component: () => import('@/views/weighing/WeighingTouch.vue'), meta: { title: '触摸屏过磅', perm: 'weighing:touch' } },
{ path: 'payments', name: 'Payments', component: () => import('@/views/payments/PaymentList.vue'), meta: { title: '电子支付' } },
{ path: 'invoices', name: 'Invoices', component: () => import('@/views/invoices/InvoiceList.vue'), meta: { title: '反向开票' } },
{ path: 'reports/purchase', name: 'ReportPurchase', component: () => import('@/views/reports/ReportPurchase.vue'), meta: { title: '收购报表' } },
{ path: 'reports/payment', name: 'ReportPayment', component: () => import('@/views/reports/ReportPayment.vue'), meta: { title: '付款报表' } },
{ path: 'reports/invoice', name: 'ReportInvoice', component: () => import('@/views/reports/ReportInvoice.vue'), meta: { title: '开票报表' } },
{ path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } }
{ path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } },
// 系统管理
{ path: 'system/roles', name: 'RoleList', component: () => import('@/views/system/RoleList.vue'), meta: { title: '角色权限', perm: 'system:role:list' } },
{ path: 'system/menus', name: 'MenuList', component: () => import('@/views/system/MenuList.vue'), meta: { title: '菜单管理', perm: 'system:menu:list' } },
{ path: 'system/dicts', name: 'DictList', component: () => import('@/views/system/DictList.vue'), meta: { title: '数据字典', perm: 'system:dict:list' } },
// 通知公告
{ path: 'notify/announcements', name: 'AnnouncementList', component: () => import('@/views/notify/AnnouncementList.vue'), meta: { title: '通知公告', perm: 'announcement:list' } },
// 关于
{ path: 'about', name: 'About', component: () => import('@/views/about/About.vue'), meta: { title: '关于系统', perm: 'about:view' } }
]
},
{ path: '/dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '可视化大屏', public: false } },
@@ -31,6 +39,7 @@ const router = createRouter({
routes
})
// 权限守卫:登录 + 页面级权限(从 localStorage 权限标识判断)
router.beforeEach((to) => {
const token = localStorage.getItem('token')
if (!to.meta.public && !token) {
@@ -39,6 +48,15 @@ router.beforeEach((to) => {
if (to.path === '/login' && token) {
return '/home'
}
const perm = to.meta.perm as string | undefined
if (perm && token) {
const user = JSON.parse(localStorage.getItem('user') || 'null')
const permissions = JSON.parse(localStorage.getItem('permissions') || '[]') as string[]
const isSuper = user?.role === 'SuperAdmin'
if (!isSuper && !permissions.includes(perm)) {
return '/home'
}
}
return true
})
+20 -2
View File
@@ -1,10 +1,12 @@
import { defineStore } from 'pinia'
import type { UserInfo } from '@/types'
import type { MyPermissions, SysMenu, UserInfo } from '@/types'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user') || 'null') as UserInfo | null
user: JSON.parse(localStorage.getItem('user') || 'null') as UserInfo | null,
permissions: JSON.parse(localStorage.getItem('permissions') || '[]') as string[],
menus: JSON.parse(localStorage.getItem('menus') || '[]') as SysMenu[]
}),
getters: {
isLoggedIn: (state) => !!state.token,
@@ -22,11 +24,27 @@ export const useAuthStore = defineStore('auth', {
this.user = user
localStorage.setItem('user', JSON.stringify(user))
},
setPermissions(data: MyPermissions | null) {
const permissions = data?.permissions ?? []
const menus = data?.menus ?? []
this.permissions = permissions
this.menus = menus
localStorage.setItem('permissions', JSON.stringify(permissions))
localStorage.setItem('menus', JSON.stringify(menus))
},
hasPermission(perm: string) {
if (this.isSuperAdmin) return true
return this.permissions.includes(perm)
},
logout() {
this.token = ''
this.user = null
this.permissions = []
this.menus = []
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
localStorage.removeItem('menus')
}
}
})
+85
View File
@@ -0,0 +1,85 @@
/* ============ 全局主题(MES 蓝色风格) ============ */
:root {
/* 主色 */
--el-color-primary: #1a73e8;
--el-color-primary-light-3: #6ba4f0;
--el-color-primary-light-5: #9cc3f5;
--el-color-primary-light-7: #cfe0fb;
--el-color-primary-light-8: #e2edfc;
--el-color-primary-light-9: #f0f6fe;
--el-color-primary-dark-2: #155cc4;
/* 成功 / 警告 / 危险 */
--el-color-success: #34a853;
--el-color-success-light-3: #7cc58f;
--el-color-success-light-5: #a9d9b6;
--el-color-success-light-7: #d4ecdb;
--el-color-success-light-8: #e4f4e9;
--el-color-success-light-9: #f0faf3;
--el-color-success-dark-2: #2a8642;
--el-color-warning: #f5a623;
--el-color-warning-light-3: #f8c26d;
--el-color-warning-light-5: #fad99d;
--el-color-warning-light-7: #fcefcf;
--el-color-warning-light-8: #fdf5e0;
--el-color-warning-light-9: #fefaf0;
--el-color-warning-dark-2: #c4851c;
--el-color-danger: #e54545;
--el-color-danger-light-3: #ef7f7f;
--el-color-danger-light-5: #f5a9a9;
--el-color-danger-light-7: #fad3d3;
--el-color-danger-light-8: #fce2e2;
--el-color-danger-light-9: #fef0f0;
--el-color-danger-dark-2: #b73737;
/* 信息 */
--el-color-info: #6b7280;
--el-color-info-light-3: #9aa0ab;
--el-color-info-light-5: #b9bdc5;
--el-color-info-light-7: #d9dce1;
--el-color-info-light-8: #e7e9ec;
--el-color-info-light-9: #f4f5f6;
--el-color-info-dark-2: #565b66;
/* 基础色 */
--el-text-color-primary: #1f2d3d;
--el-text-color-regular: #4b5563;
--el-border-color: #e5e7eb;
--el-border-color-light: #ebedf0;
--el-border-color-lighter: #f0f1f3;
--el-fill-color-light: #f7f8fa;
--el-fill-color-lighter: #fafbfc;
--el-bg-color-page: #f0f2f5;
}
/* 表格浅蓝表头(MES 风格) */
.el-table th.el-table__cell {
background: #f5f9ff !important;
color: #1f2d3d;
font-weight: 600;
}
/* 页面通用背景 */
html, body, #app {
background: #f0f2f5;
}
/* 顶栏图标按钮 */
.hdr-icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
color: #4a5568;
transition: all 0.2s;
}
.hdr-icon-btn:hover {
background: #f0f5ff;
color: #1a73e8;
}
+146 -2
View File
@@ -4,6 +4,8 @@ export interface UserInfo {
realName: string
phone: string
role: string
roleId: number | null
roleName: string | null
orgId: number | null
orgName: string | null
orgType: string | null
@@ -46,6 +48,8 @@ export interface SysUser {
realName: string
phone: string
role: string
roleId: number | null
roleName: string | null
orgId: number | null
orgName: string | null
isActive: boolean
@@ -53,6 +57,109 @@ export interface SysUser {
createdAt: string
}
// ---------- 系统管理(RBAC / 数据字典) ----------
export interface DictItem {
id: number
dictId: number
label: string
value: string
ext: string
isDefault: boolean
enabled: boolean
sort: number
}
export interface DictItemDto {
id?: number
dictId?: number
label: string
value: string
ext: string
isDefault: boolean
enabled: boolean
sort: number
}
export interface Dict {
id: number
name: string
code: string
remark: string
isSystem: boolean
sort: number
itemCount: number
}
export interface DictDetail {
dict: Dict
items: DictItem[]
}
export interface DictCodeItem {
label: string
value: string
ext: string
isDefault: boolean
}
export interface SysMenu {
id: number
parentId: number | null
name: string
path: string
component: string
icon: string
/** directory=目录 menu=菜单 button=按钮 */
type: string
permission: string
visible: boolean
sort: number
children: SysMenu[]
}
export interface SysRole {
id: number
name: string
code: string
description: string
isSystem: boolean
menuIds: number[]
}
export interface MyPermissions {
menus: SysMenu[]
permissions: string[]
}
export interface Announcement {
id: number
title: string
content: string
type: string
scopeOrgIds: string
isPinned: boolean
publisherName: string
publishedAt: string
createdAt: string
isRead: boolean
}
export interface MyAnnouncements {
items: Announcement[]
total: number
unread: number
}
export interface AboutInfo {
appName: string
version: string
copyright: string
company: string
license: string
description: string
builtAt: string
}
export interface Farmer {
id: number
name: string
@@ -113,12 +220,12 @@ export interface IdCardOcrResult {
message: string
}
/** 身份证上传结果(高拍仪/本地拍照) */
/** 身份证上传结果(高拍仪/本地拍照);反面仅存档,ocr 为 null */
export interface IdCardUploadResult {
url: string
key: string
side: string
ocr: IdCardOcrResult
ocr: IdCardOcrResult | null
}
/** 手机传图二维码会话 */
@@ -149,6 +256,7 @@ export interface FarmerOption {
village: string
groupName: string
idCard: string
pinyinInitials?: string
}
export interface Product {
@@ -162,6 +270,31 @@ export interface Product {
createdAt: string
}
export interface PurchaseWeigh {
id: number
sortNo: number
grade: string
priceType: string
unitPrice: number
grossWeight: number
tareWeight: number
netWeight: number
boxCount: number
amount: number
createdAt: string
}
export interface PurchaseTransfer {
id: number
transferNo: string
fromFarmer: string
toFarmer: string
toIdCard: string
reason: string
operatorName: string | null
createdAt: string
}
export interface PurchaseOrder {
id: number
orderNo: string
@@ -187,6 +320,17 @@ export interface PurchaseOrder {
weighOutAt: string | null
notes: string
createdAt: string
weighs: PurchaseWeigh[]
boxCount: number
}
export interface PurchaseSummary {
orderCount: number
totalNetWeight: number
avgPrice: number
totalAmount: number
farmerCount: number
todayOrderCount: number
}
export interface PaymentRecord {
+26
View File
@@ -0,0 +1,26 @@
declare module 'lunar-javascript' {
export class Solar {
static fromDate(date: Date): Solar
static fromYmd(year: number, month: number, day: number): Solar
getYear(): number
getMonth(): number
getDay(): number
getLunar(): Lunar
}
export class Lunar {
static fromDate(date: Date): Lunar
static fromYmd(year: number, month: number, day: number): Lunar
getYear(): number
getMonth(): number
getDay(): number
getYearInChinese(): string
getMonthInChinese(): string
getDayInChinese(): string
getYearInGanZhi(): string
getShengxiao(): string
getFestivals(): string[]
getOtherFestivals(): string[]
getJieQi(): string
}
}
+151 -49
View File
@@ -1,14 +1,14 @@
<template>
<template>
<div class="screen-wrap" @click="onWrapClick">
<div class="screen" :style="screenStyle">
<!-- 顶栏天气 + 标题 + 时钟 -->
<header class="screen-header">
<div class="header-left">
<div v-if="weather" class="weather">
<span class="w-icon">{{ weather.icon }}</span>
<div v-if="weather.loaded" class="weather">
<el-icon :size="36" class="w-icon"><component :is="weather.current.icon" /></el-icon>
<div class="w-info">
<div class="w-city">{{ weather.city }}{{ weather.region ? ' · ' + weather.region : '' }}</div>
<div class="w-text">{{ weather.text }} <b>{{ weather.tempC }}°C</b></div>
<div class="w-text">{{ weather.current.text }} <b>{{ weather.current.temperature }}°C</b></div>
<div class="w-sub"> {{ weather.todayMin }}~{{ weather.todayMax }}°C<template v-if="weather.wind"> · {{ weather.wind }}</template></div>
</div>
</div>
@@ -98,7 +98,8 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import { getOverview, getTrend, getProductDistribution, getFarmerTop, getStationComparison, getPriceTop, getIncomeTop, getRealtimeWeighing, getWeather } from '@/api'
import { getOverview, getTrend, getProductDistribution, getFarmerTop, getStationComparison, getPriceTop, getIncomeTop, getRealtimeWeighing } from '@/api'
import { useWeather } from '@/composables/useWeather'
import type { DateRange } from '@/api'
const now = ref(dayjs().format('YYYY-MM-DD HH:mm:ss'))
@@ -170,17 +171,12 @@ function applyCustom() {
const trendTitle = computed(() => `收购趋势 · ${rangeLabel.value}`)
// ---------- 天气 ----------
const weather = ref<any>(null)
function loadWeather() {
getWeather()
.then((w) => { weather.value = w })
.catch(() => { weather.value = null })
}
// ---------- 天气(与顶部 banner 同一数据源) ----------
const weather = useWeather()
// ---------- KPI ----------
const kpis = ref([
{ label: '区间收购金额(元)', value: '--', color: '#67c23a' },
{ label: '区间收购金额(元)', value: '--', color: '#1a73e8' },
{ label: '收购单数', value: '--', color: '#409eff' },
{ label: '收购重量(kg)', value: '--', color: '#e6a23c' },
{ label: '付款金额(元)', value: '--', color: '#f56c6c' },
@@ -198,8 +194,8 @@ const priceRef = ref<HTMLElement>()
const incomeRef = ref<HTMLElement>()
const charts: echarts.ECharts[] = []
const axisStyle = { color: '#8fa7a0', fontSize: 12 }
const legendStyle = { textStyle: { color: '#cfe3d6' } }
const axisStyle = { color: '#8fa3b8', fontSize: 12 }
const legendStyle = { textStyle: { color: '#b8d8f0' } }
const initCharts = () => {
if (charts.length) return
@@ -232,8 +228,8 @@ function barRank(el: number, rows: { name: string; value: number; extra?: string
yAxis: { type: 'category', data, axisLabel: axisStyle, axisLine: { lineStyle: { color: '#3f5a4c' } }, axisTick: { show: false } },
series: [{
type: 'bar', barWidth: 16, data: values,
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#1f4d2f' }, { offset: 1, color }]), borderRadius: [0, 6, 6, 0] },
label: { show: true, position: 'right', color: '#cfe3d6', formatter: (p: any) => values[p.dataIndex].toLocaleString() }
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#1f3a6e' }, { offset: 1, color }]), borderRadius: [0, 6, 6, 0] },
label: { show: true, position: 'right', color: '#b8d8f0', formatter: (p: any) => values[p.dataIndex].toLocaleString() }
}]
}, true)
}
@@ -248,7 +244,7 @@ async function loadData() {
getRealtimeWeighing()
])
kpis.value = [
{ label: '区间收购金额(元)', value: o.amount.toLocaleString(), color: '#67c23a' },
{ label: '区间收购金额(元)', value: o.amount.toLocaleString(), color: '#1a73e8' },
{ label: '收购单数', value: String(o.orders), color: '#409eff' },
{ label: '收购重量(kg)', value: o.netWeight.toLocaleString(), color: '#e6a23c' },
{ label: '付款金额(元)', value: o.paid.toLocaleString(), color: '#f56c6c' },
@@ -266,7 +262,7 @@ async function loadData() {
yAxis: [{ type: 'value', name: '金额', axisLabel: axisStyle, splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } } },
{ type: 'value', name: '重量', axisLabel: axisStyle, splitLine: { show: false } }],
series: [
{ name: '收购金额', type: 'line', smooth: true, data: trend.map((t) => Math.round(t.amount)), itemStyle: { color: '#67c23a' }, areaStyle: { opacity: 0.2 } },
{ name: '收购金额', type: 'line', smooth: true, data: trend.map((t) => Math.round(t.amount)), itemStyle: { color: '#1a73e8' }, areaStyle: { opacity: 0.2 } },
{ name: '收购重量', type: 'line', smooth: true, yAxisIndex: 1, data: trend.map((t) => Math.round(t.netWeight)), itemStyle: { color: '#409eff' } }
]
})
@@ -278,13 +274,13 @@ async function loadData() {
series: [{
type: 'pie', radius: ['38%', '62%'], center: ['50%', '44%'],
data: dist.map((d) => ({ name: d.name, value: Math.round(d.value) })),
label: { color: '#cfe3d6', formatter: '{b}\n{d}%' },
itemStyle: { borderRadius: 6, borderColor: '#0b2015', borderWidth: 2 }
label: { color: '#b8d8f0', formatter: '{b}\n{d}%' },
itemStyle: { borderRadius: 6, borderColor: '#0a1630', borderWidth: 2 }
}]
})
// 农户金额排行
barRank(2, top.map((t) => ({ name: t.name, value: t.amount, extra: `${t.count} 单 · ${t.netWeight.toLocaleString()} kg` })), '#67c23a', '收购金额(元)')
barRank(2, top.map((t) => ({ name: t.name, value: t.amount, extra: `${t.count} 单 · ${t.netWeight.toLocaleString()} kg` })), '#1a73e8', '收购金额(元)')
// 站点对比
charts[3].setOption({
@@ -296,7 +292,7 @@ async function loadData() {
series: [{
name: '收购金额', type: 'bar', data: stations.map((s) => Math.round(s.amount)),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#409eff' }, { offset: 1, color: '#1f6fbf' }]), borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top', color: '#cfe3d6' }
label: { show: true, position: 'top', color: '#b8d8f0' }
}]
})
@@ -313,7 +309,6 @@ onMounted(() => {
window.addEventListener('resize', fitScreen)
initCharts()
loadData()
loadWeather()
timer = setInterval(() => {
now.value = dayjs().format('YYYY-MM-DD HH:mm:ss')
loadData()
@@ -335,15 +330,15 @@ function onWrapClick() { /* 防止误触聚焦 */ }
width: 100vw;
height: 100vh;
overflow: hidden;
background: #06120c;
background: #050b1c;
position: fixed;
top: 0;
left: 0;
}
.screen {
position: relative;
background: radial-gradient(ellipse at top, #103627 0%, #08170f 60%, #06120c 100%);
color: #e6f2ea;
background: radial-gradient(ellipse at top, #0a1a3a 0%, #060d1e 60%, #050b1c 100%);
color: #e6eff8;
padding: 12px 20px 8px;
box-sizing: border-box;
font-family: 'DIN Alternate', 'PingFang SC', 'Microsoft YaHei', sans-serif;
@@ -358,14 +353,14 @@ function onWrapClick() { /* 防止误触聚焦 */ }
justify-content: space-between;
height: 66px;
padding: 0 10px 8px;
border-bottom: 1px solid rgba(63,174,96,0.35);
border-bottom: 1px solid rgba(26,115,232,0.35);
position: relative;
}
.screen-header h1 {
margin: 0;
font-size: 30px;
letter-spacing: 8px;
color: #7ee29a;
color: #7ed6f0;
text-shadow: 0 0 20px rgba(103,194,58,0.5);
text-align: center;
position: absolute;
@@ -376,12 +371,12 @@ function onWrapClick() { /* 防止误触聚焦 */ }
.header-right { width: 380px; text-align: right; }
.weather { display: flex; align-items: center; gap: 12px; }
.weather-off { color: #6a8576; font-size: 14px; }
.w-icon { font-size: 40px; line-height: 1; }
.w-icon { color: #7ed6f0; line-height: 1; }
.w-city { color: #9fceb1; font-size: 15px; font-weight: 600; }
.w-text { color: #e6f2ea; font-size: 15px; }
.w-text { color: #e6eff8; font-size: 15px; }
.w-text b { color: #ffd24d; font-size: 18px; margin-left: 2px; }
.w-sub { color: #8fa7a0; font-size: 12px; margin-top: 2px; }
.clock { color: #8fa7a0; font-size: 18px; letter-spacing: 1px; }
.w-sub { color: #8fa3b8; font-size: 12px; margin-top: 2px; }
.clock { color: #8fa3b8; font-size: 18px; letter-spacing: 1px; }
/* ---------- 快捷日期 ---------- */
.toolbar {
@@ -391,9 +386,9 @@ function onWrapClick() { /* 防止误触聚焦 */ }
padding: 12px 8px 6px;
}
.range-btn {
border: 1px solid rgba(63,174,96,0.4);
background: rgba(63,174,96,0.12);
color: #a9c7b5;
border: 1px solid rgba(26,115,232,0.4);
background: rgba(26,115,232,0.12);
color: #a9c0d8;
padding: 7px 20px;
border-radius: 6px;
cursor: pointer;
@@ -401,14 +396,39 @@ function onWrapClick() { /* 防止误触聚焦 */ }
letter-spacing: 1px;
transition: all 0.2s;
}
.range-btn:hover { color: #e6f2ea; border-color: #67c23a; }
.range-btn:hover { color: #e6eff8; border-color: #1a73e8; }
.range-btn.active {
background: linear-gradient(160deg, #3fae60, #2c7a45);
background: linear-gradient(160deg, #1a73e8, #155cc4);
color: #fff;
border-color: #67c23a;
box-shadow: 0 0 12px rgba(103,194,58,0.4);
border-color: #1a73e8;
box-shadow: 0 0 12px rgba(26,115,232,0.4);
}
.date-picker { width: 330px; }
.date-picker :deep(.el-input__wrapper) {
--el-fill-color-blank: rgba(23, 52, 38, 0.75);
--el-border-color: rgba(26, 115, 232, 0.45);
--el-border-color-hover: rgba(26, 115, 232, 0.7);
--el-border-color-focus: #1a73e8;
box-shadow: 0 0 0 1px rgba(26, 115, 232, 0.45) inset;
border-radius: 6px;
}
.date-picker :deep(.el-input__inner) {
color: #e6eff8;
font-weight: 500;
}
.date-picker :deep(.el-range-separator) {
color: #8fa3b8;
}
.date-picker :deep(.el-range-input) {
color: #e6eff8;
caret-color: #e6eff8;
}
.date-picker :deep(.el-range-input::placeholder) {
color: #6a8576;
}
.date-picker :deep(.el-input__icon) {
color: #7ed6f0;
}
.range-label { color: #ffd24d; font-size: 14px; letter-spacing: 1px; }
.refresh-tip { margin-left: auto; color: #54725f; font-size: 12px; }
@@ -420,14 +440,14 @@ function onWrapClick() { /* 防止误触聚焦 */ }
padding: 8px 0;
}
.kpi {
background: linear-gradient(160deg, rgba(63,174,96,0.15), rgba(63,174,96,0.04));
border: 1px solid rgba(63,174,96,0.35);
background: linear-gradient(160deg, rgba(26,115,232,0.15), rgba(26,115,232,0.04));
border: 1px solid rgba(26,115,232,0.35);
border-radius: 10px;
text-align: center;
padding: 10px 6px;
}
.kpi-num { font-size: 28px; font-weight: 700; }
.kpi-label { font-size: 13px; color: #a9c7b5; margin-top: 4px; }
.kpi-label { font-size: 13px; color: #a9c0d8; margin-top: 4px; }
/* ---------- 图表区(4 列 x 2 行) ---------- */
.charts {
@@ -438,8 +458,8 @@ function onWrapClick() { /* 防止误触聚焦 */ }
height: calc(1080px - 66px - 54px - 96px - 34px);
}
.panel {
background: rgba(15, 43, 29, 0.6);
border: 1px solid rgba(63,174,96,0.25);
background: rgba(13, 27, 52, 0.6);
border: 1px solid rgba(26,115,232,0.25);
border-radius: 10px;
padding: 10px;
position: relative;
@@ -450,7 +470,7 @@ function onWrapClick() { /* 防止误触聚焦 */ }
color: #9fceb1;
letter-spacing: 1px;
padding-left: 8px;
border-left: 3px solid #3fae60;
border-left: 3px solid #1a73e8;
margin-bottom: 4px;
}
.chart { height: calc(100% - 28px); }
@@ -465,10 +485,10 @@ function onWrapClick() { /* 防止误触聚焦 */ }
.realtime-list { height: calc(100% - 28px); overflow-y: auto; padding: 4px 4px; }
.rt-item { display: flex; gap: 8px; padding: 8px 6px; border-bottom: 1px dashed rgba(255,255,255,0.08); font-size: 14px; }
.rt-name { color: #7ee29a; width: 72px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-prod { color: #cfe3d6; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-name { color: #7ed6f0; width: 72px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-prod { color: #b8d8f0; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-weight { color: #e6a23c; }
.rt-time { color: #8fa7a0; }
.rt-time { color: #8fa3b8; }
.rt-empty { color: #6a8576; text-align: center; padding: 30px 0; }
.screen-footer {
@@ -479,3 +499,85 @@ function onWrapClick() { /* 防止误触聚焦 */ }
letter-spacing: 2px;
}
</style>
<style>
/* 大屏日期选择器弹窗深色主题 */
.dash-date-pop.el-picker-panel,
.dash-date-pop .el-picker-panel__body,
.dash-date-pop .el-date-range-picker__content {
background: #0d1f17;
color: #e6eff8;
}
.dash-date-pop .el-date-table th {
color: #8fa3b8;
border-bottom: 1px solid rgba(26, 115, 232, 0.2);
}
.dash-date-pop .el-date-table td .el-date-table-cell .el-date-table-cell__text {
color: #cfe0ee;
}
.dash-date-pop .el-date-table td.available:hover .el-date-table-cell__text {
background: rgba(26, 115, 232, 0.35);
color: #fff;
}
.dash-date-pop .el-date-table td.in-range .el-date-table-cell {
background: rgba(26, 115, 232, 0.25);
}
.dash-date-pop .el-date-table td.in-range .el-date-table-cell__text {
background: transparent;
}
.dash-date-pop .el-date-table td.start-date .el-date-table-cell,
.dash-date-pop .el-date-table td.end-date .el-date-table-cell {
background: rgba(26, 115, 232, 0.55);
}
.dash-date-pop .el-date-table td.start-date .el-date-table-cell__text,
.dash-date-pop .el-date-table td.end-date .el-date-table-cell__text,
.dash-date-pop .el-date-table td.today .el-date-table-cell__text {
background: #1a73e8;
color: #fff;
}
.dash-date-pop .el-picker-panel__icon-btn {
color: #8fa3b8;
}
.dash-date-pop .el-date-range-picker__header {
color: #e6eff8;
}
.dash-date-pop .el-date-range-picker__header-text {
color: #e6eff8;
}
.dash-date-pop .el-date-range-picker__header-label {
color: #9fceb1;
}
.dash-date-pop .el-picker-panel__sidebar {
background: #0a1711;
border-right: 1px solid rgba(26, 115, 232, 0.2);
}
.dash-date-pop .el-picker-panel__shortcut {
color: #cfe0ee;
}
.dash-date-pop .el-picker-panel__shortcut:hover {
color: #fff;
background: rgba(26, 115, 232, 0.2);
}
.dash-date-pop .el-picker-panel__footer {
background: #0d1f17;
}
/* 大屏日期输入框深色主题(必须全局覆盖 Element Plus 默认白底) */
.date-picker.el-date-editor.el-date-editor--daterange {
background-color: rgba(23, 52, 38, 0.75) !important;
box-shadow: 0 0 0 1px rgba(26, 115, 232, 0.45) inset !important;
border-radius: 6px;
}
.date-picker.el-date-editor .el-range-input {
color: #e6eff8;
}
.date-picker.el-date-editor .el-range-input::placeholder {
color: #6a8576;
}
.date-picker.el-date-editor .el-range-separator {
color: #8fa3b8;
}
.date-picker.el-date-editor .el-input__icon {
color: #7ed6f0;
}
</style>
+363 -90
View File
@@ -1,70 +1,134 @@
<template>
<div class="home">
<el-card class="welcome">
<div>
<!-- 欢迎横幅 -->
<div class="welcome">
<div class="welcome-left">
<div class="welcome-title">{{ greeting }}{{ auth.user?.realName || auth.user?.username }}</div>
<div class="welcome-sub">欢迎使用农易富农产品收购交易平台 · 今日 {{ today }}</div>
<div class="welcome-sub">{{ todayText }} · 欢迎使用农易富农产品收购交易平台</div>
</div>
<div class="welcome-actions">
<el-button type="primary" size="large" @click="$router.push('/weighing/touch')">
<el-icon><Odometer /></el-icon>&nbsp;触摸屏过磅
</el-button>
<el-button type="success" size="large" @click="openDashboard">
<el-icon><Monitor /></el-icon>&nbsp;可视化大屏
</el-button>
<div class="welcome-right">
<div v-if="weather.loaded" class="weather">
<el-icon class="w-icon"><component :is="weather.current.icon" /></el-icon>
<span class="w-text">{{ weather.current.text }} {{ weather.current.temperature }}°C</span>
<span class="w-sub">{{ weather.city }}</span>
</div>
<button class="btn-primary" @click="$router.push('/weighing/touch')">
<el-icon><Iphone /></el-icon>触摸屏过磅
</button>
<button class="btn-ghost" @click="openDashboard">
<el-icon><Monitor /></el-icon>可视化大屏
</button>
</div>
</el-card>
</div>
<el-row :gutter="16" class="kpi-row">
<el-col :span="6" v-for="k in kpis" :key="k.label">
<el-card class="kpi-card" :class="k.color">
<div class="kpi-icon"><el-icon :size="26"><component :is="k.icon" /></el-icon></div>
<div>
<div class="kpi-value">{{ k.value }}</div>
<div class="kpi-label">{{ k.label }}</div>
<!-- KPI -->
<div class="kpi-row">
<div v-for="k in kpis" :key="k.label" class="kpi-card">
<div class="kpi-icon" :style="{ background: k.bg }">
<el-icon :size="26" color="#fff"><component :is="k.icon" /></el-icon>
</div>
<div class="kpi-info">
<div class="kpi-label">{{ k.label }}</div>
<div class="kpi-value">{{ k.value }}</div>
<div class="kpi-foot">
<template v-if="k.diff !== null">
较昨日 <span :class="k.diff >= 0 ? 'up' : 'down'">{{ k.diff >= 0 ? '+' : '' }}{{ k.diff.toFixed(1) }}%</span>
</template>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</div>
<el-row :gutter="16">
<el-col :span="14">
<el-card header="快捷入口">
<div class="quick-grid">
<div v-for="q in quickLinks" :key="q.path" class="quick-item" @click="$router.push(q.path)">
<el-icon :size="28" :color="q.color"><component :is="q.icon" /></el-icon>
<span>{{ q.label }}</span>
<!-- 图表网格 -->
<div class="dash-grid">
<div class="panel span2">
<div class="panel-head">
<span class="panel-title">收购趋势</span>
<span class="panel-extra"> 7 </span>
</div>
<div ref="trendRef" class="chart"></div>
</div>
<div class="panel">
<div class="panel-head">
<span class="panel-title">品种收购占比</span>
<span class="panel-extra"> 7 </span>
</div>
<div ref="pieRef" class="chart"></div>
</div>
<div class="panel span2">
<div class="panel-head">
<span class="panel-title">正在过磅</span>
<span class="panel-extra">实时数据</span>
</div>
<el-table :data="weighing" class="mes-table" :header-cell-style="headerStyle" v-loading="loading" max-height="282">
<el-table-column prop="orderNo" label="单号" width="170" />
<el-table-column prop="farmerName" label="农户" min-width="110" />
<el-table-column prop="productName" label="品种" width="120" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-tag" :class="row.weighOutAt ? 'done' : 'busy'">{{ row.weighOutAt ? '已完成' : '过磅中' }}</span>
</template>
</el-table-column>
<el-table-column label="毛重(kg)" width="120" align="right">
<template #default="{ row }">
<b class="gross">{{ row.grossWeight.toLocaleString() }}</b>
</template>
</el-table-column>
</el-table>
<div v-if="!loading && !weighing.length" class="empty">当前无进行中的过磅单</div>
</div>
<div class="panel">
<div class="panel-head">
<span class="panel-title">农户收购排行</span>
<span class="panel-extra"> 7 </span>
</div>
<div ref="rankRef" class="chart"></div>
</div>
<div class="panel span2">
<div class="panel-head">
<span class="panel-title">快捷入口</span>
</div>
<div class="quick-grid">
<div v-for="q in quickLinks" :key="q.path" class="quick-item" @click="$router.push(q.path)">
<div class="quick-icon" :style="{ background: q.bg }">
<el-icon :size="22" color="#fff"><component :is="q.icon" /></el-icon>
</div>
<span>{{ q.label }}</span>
</div>
</el-card>
</el-col>
<el-col :span="10">
<el-card header="正在过磅(实时)">
<el-table :data="weighing" size="small" max-height="260">
<el-table-column prop="orderNo" label="单号" width="150" />
<el-table-column prop="farmerName" label="农户" />
<el-table-column prop="productName" label="品种" width="90" />
<el-table-column prop="grossWeight" label="毛重(kg)" width="100" align="right" />
</el-table>
<div v-if="!weighing.length" class="empty">当前无进行中的过磅单</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
<div class="panel">
<div class="panel-head">
<span class="panel-title">收购单价排行</span>
<span class="panel-extra"> 7 </span>
</div>
<div ref="priceRef" class="chart"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { getOverview, getWeighingList } from '@/api'
import { getOverview, getTrend, getProductDistribution, getFarmerTop, getPriceTop, getWeighingList } from '@/api'
import { useWeather } from '@/composables/useWeather'
import type { PurchaseOrder } from '@/types'
import dayjs from 'dayjs'
import * as echarts from 'echarts'
const auth = useAuthStore()
const router = useRouter()
const today = dayjs().format('YYYY年MM月DD日')
const todayText = computed(() => {
const d = dayjs()
const week = '日一二三四五六'[d.day()]
return `${d.format('YYYY年MM月DD日')} 星期${week}`
})
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
@@ -74,68 +138,277 @@ const greeting = computed(() => {
return '晚上好'
})
const kpis = ref([
{ label: '今日收购金额', value: '¥ --', icon: 'Money', color: 'green' },
{ label: '今日收购单数', value: '--', icon: 'Document', color: 'blue' },
{ label: '本月收购重量', value: '-- kg', icon: 'ScaleToOriginal', color: 'orange' },
{ label: '今日支付金额', value: '¥ --', icon: 'Wallet', color: 'purple' }
// ---------- KPI ----------
interface Kpi { label: string; value: string; icon: string; bg: string; diff: number | null }
const kpis = ref<Kpi[]>([
{ label: '今日收购金额', value: '¥ --', icon: 'Money', bg: '#1a73e8', diff: null },
{ label: '今日收购单数', value: '--', icon: 'Document', bg: '#34a853', diff: null },
{ label: '本月收购重量', value: '-- kg', icon: 'ScaleToOriginal', bg: '#fb8c00', diff: null },
{ label: '今日支付金额', value: '¥ --', icon: 'Wallet', bg: '#7b5cd6', diff: null }
])
const fmt = (v: number) => '¥ ' + v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const diffPct = (cur: number, prev: number) => (prev > 0 ? ((cur - prev) / prev) * 100 : null)
// ---------- 数据 ----------
const weighing = ref<PurchaseOrder[]>([])
const loading = ref(true)
const weather = useWeather()
const quickLinks = [
{ label: '过磅称重', path: '/weighing', icon: 'Odometer', color: '#409eff' },
{ label: '触摸屏过磅', path: '/weighing/touch', icon: 'Iphone', color: '#67c23a' },
{ label: '农户管理', path: '/farmers', icon: 'User', color: '#e6a23c' },
{ label: '电子支付', path: '/payments', icon: 'Wallet', color: '#f56c6c' },
{ label: '反向开票', path: '/invoices', icon: 'Tickets', color: '#909399' },
{ label: '收购报表', path: '/reports/purchase', icon: 'TrendCharts', color: '#3fae60' }
{ label: '过磅称重', path: '/weighing', icon: 'Odometer', bg: '#1a73e8' },
{ label: '触摸屏过磅', path: '/weighing/touch', icon: 'Iphone', bg: '#34a853' },
{ label: '农户管理', path: '/farmers', icon: 'User', bg: '#fb8c00' },
{ label: '电子支付', path: '/payments', icon: 'Wallet', bg: '#7b5cd6' },
{ label: '反向开票', path: '/invoices', icon: 'Tickets', bg: '#00bcd4' },
{ label: '收购报表', path: '/reports/purchase', icon: 'TrendCharts', bg: '#e91e63' }
]
const fmt = (v: number) => '¥ ' + v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
// ---------- 图表 ----------
const trendRef = ref<HTMLElement>()
const pieRef = ref<HTMLElement>()
const rankRef = ref<HTMLElement>()
const priceRef = ref<HTMLElement>()
const charts: echarts.ECharts[] = []
const palette = ['#1a73e8', '#34a853', '#fb8c00', '#7b5cd6', '#00bcd4', '#e91e63', '#8d6e63', '#607d8b']
const headerStyle = { background: '#f5f9ff', color: '#1f2d3d', fontWeight: 600 }
function barRank(el: number, rows: { name: string; value: number }[], color: string) {
charts[el].setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (ps: any) => {
const i = rows.length - 1 - ps[0].dataIndex
const r = rows[i]
return `${r.name}<br/>金额: ¥${r.value.toLocaleString()}`
} },
grid: { left: 8, right: 46, top: 10, bottom: 8, containLabel: true },
xAxis: { type: 'value', axisLabel: { show: false }, splitLine: { show: false } },
yAxis: { type: 'category', data: rows.map((r) => r.name).reverse(), axisLabel: { color: '#666', fontSize: 12 }, axisLine: { show: false }, axisTick: { show: false } },
series: [{
type: 'bar', barWidth: 12, data: rows.map((r) => Math.round(r.value)).reverse(),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: color + 'cc' }, { offset: 1, color }]), borderRadius: [0, 6, 6, 0] },
label: { show: true, position: 'right', color: '#333', fontSize: 11, formatter: (p: any) => p.value.toLocaleString() }
}]
}, true)
}
async function loadData() {
const today = dayjs().format('YYYY-MM-DD')
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
const from7 = dayjs().subtract(6, 'day').format('YYYY-MM-DD')
try {
const [o, oPrev, m, trend, dist, top, prices, rt] = await Promise.all([
getOverview({ from: today, to: today }),
getOverview({ from: yesterday, to: yesterday }),
getOverview({ from: dayjs().startOf('month').format('YYYY-MM-DD'), to: today }),
getTrend({ from: from7, to: today }),
getProductDistribution({ from: from7, to: today }),
getFarmerTop({ from: from7, to: today, limit: 6 }),
getPriceTop({ from: from7, to: today, limit: 6 }),
getWeighingList()
])
kpis.value[0].value = fmt(o.amount)
kpis.value[0].diff = diffPct(o.amount, oPrev.amount)
kpis.value[1].value = String(o.orders)
kpis.value[1].diff = diffPct(o.orders, oPrev.orders)
kpis.value[2].value = m.netWeight.toLocaleString() + ' kg'
kpis.value[3].value = fmt(o.paid)
kpis.value[3].diff = diffPct(o.paid, oPrev.paid)
weighing.value = rt
// 趋势
charts[0].setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['收购金额', '收购重量'], top: 0, textStyle: { color: '#666', fontSize: 12 } },
grid: { left: 56, right: 24, top: 34, bottom: 24 },
xAxis: { type: 'category', boundaryGap: false, data: trend.map((t) => t.date.slice(5)), axisLine: { lineStyle: { color: '#e5e5e5' } }, axisLabel: { color: '#888' }, axisTick: { show: false } },
yAxis: [
{ type: 'value', name: '金额(元)', axisLabel: { color: '#888' }, splitLine: { lineStyle: { color: '#f0f2f5' } } },
{ type: 'value', name: '重量(kg)', axisLabel: { color: '#888' }, splitLine: { show: false } }
],
series: [
{
name: '收购金额', type: 'line', smooth: true, data: trend.map((t) => Math.round(t.amount)),
itemStyle: { color: '#1a73e8' }, lineStyle: { width: 3 }, symbol: 'circle', symbolSize: 6,
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(26,115,232,0.22)' }, { offset: 1, color: 'rgba(26,115,232,0.02)' }]) }
},
{
name: '收购重量', type: 'line', smooth: true, yAxisIndex: 1, data: trend.map((t) => Math.round(t.netWeight)),
itemStyle: { color: '#34a853' }, lineStyle: { width: 2 }, symbol: 'circle', symbolSize: 5
}
]
})
// 品种占比
charts[1].setOption({
tooltip: { trigger: 'item', formatter: '{b}: {d}%' },
legend: { bottom: 0, textStyle: { color: '#666', fontSize: 12 } },
color: palette,
series: [{
type: 'pie', radius: ['46%', '68%'], center: ['50%', '44%'],
data: dist.map((d) => ({ name: d.name, value: Math.round(d.value) })),
label: { color: '#555', formatter: '{b}\n{d}%', fontSize: 12 },
labelLine: { length: 8, length2: 8 },
itemStyle: { borderRadius: 4 }
}]
})
// 农户收购排行
barRank(2, top.map((t) => ({ name: t.name, value: t.amount })), '#1a73e8')
// 收购单价排行
charts[3].setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (ps: any) => {
const i = prices.length - 1 - ps[0].dataIndex
const p = prices[i]
return `${p.name}<br/>单价: ¥${p.avgPrice.toLocaleString()} / kg`
} },
grid: { left: 8, right: 46, top: 10, bottom: 8, containLabel: true },
xAxis: { type: 'value', axisLabel: { show: false }, splitLine: { show: false } },
yAxis: { type: 'category', data: prices.map((p) => p.name).reverse(), axisLabel: { color: '#666', fontSize: 12 }, axisLine: { show: false }, axisTick: { show: false } },
series: [{
type: 'bar', barWidth: 12, data: prices.map((p) => Math.round(p.avgPrice)).reverse(),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#fb8c00cc' }, { offset: 1, color: '#fb8c00' }]), borderRadius: [0, 6, 6, 0] },
label: { show: true, position: 'right', color: '#333', fontSize: 11, formatter: (p: any) => p.value.toLocaleString() }
}]
})
} catch { /* 忽略 */ } finally {
loading.value = false
}
}
const resizeAll = () => charts.forEach((c) => c.resize())
const openDashboard = () => window.open(router.resolve('/dashboard').href, '_blank')
onMounted(async () => {
try {
const today = dayjs().format('YYYY-MM-DD')
const o = await getOverview({ from: today, to: today })
kpis.value[0].value = fmt(o.amount)
kpis.value[1].value = String(o.orders)
kpis.value[3].value = fmt(o.paid)
const m = await getOverview({ from: dayjs().startOf('month').format('YYYY-MM-DD'), to: today })
kpis.value[2].value = m.netWeight.toLocaleString() + ' kg'
} catch { /* 忽略 */ }
try {
weighing.value = await getWeighingList()
} catch { /* 忽略 */ }
if (trendRef.value) charts.push(echarts.init(trendRef.value))
if (pieRef.value) charts.push(echarts.init(pieRef.value))
if (rankRef.value) charts.push(echarts.init(rankRef.value))
if (priceRef.value) charts.push(echarts.init(priceRef.value))
window.addEventListener('resize', resizeAll)
await loadData()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeAll)
charts.forEach((c) => c.dispose())
})
</script>
<style scoped>
.home { display: flex; flex-direction: column; gap: 16px; }
.welcome { background: linear-gradient(120deg, #e8f6ec, #d4eedd); border: none; }
.welcome :deep(.el-card__body) { display: flex; justify-content: space-between; align-items: center; }
.welcome-title { font-size: 20px; font-weight: 700; color: #1f5135; }
.welcome-sub { color: #5d7a68; margin-top: 6px; }
.kpi-row { margin: 0 !important; }
.kpi-card :deep(.el-card__body) { display: flex; align-items: center; gap: 14px; }
.home {
display: flex;
flex-direction: column;
gap: 16px;
background: #f0f2f5;
}
/* ---------- 欢迎横幅 ---------- */
.welcome {
background: linear-gradient(120deg, #1a73e8 0%, #4a9aff 100%);
border-radius: 10px;
padding: 20px 24px;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4px 16px rgba(26, 115, 232, 0.22);
}
.welcome-title { font-size: 22px; font-weight: 700; }
.welcome-sub { opacity: 0.88; margin-top: 6px; font-size: 13px; }
.welcome-right { display: flex; align-items: center; gap: 12px; }
.weather {
display: flex; align-items: center; gap: 6px;
background: rgba(255, 255, 255, 0.16);
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
margin-right: 6px;
}
.w-icon { font-size: 20px; color: #fff; }
.btn-primary, .btn-ghost {
display: inline-flex; align-items: center; gap: 6px;
border: none; border-radius: 8px; cursor: pointer;
font-size: 14px; font-weight: 500;
padding: 10px 18px;
transition: all 0.2s;
}
.btn-primary { background: #fff; color: #1a73e8; }
.btn-primary:hover { background: #f0f6ff; transform: translateY(-1px); }
.btn-ghost { background: rgba(255, 255, 255, 0.16); color: #fff; }
.btn-ghost:hover { background: rgba(255, 255, 255, 0.3); transform: translateY(-1px); }
/* ---------- KPI ---------- */
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.kpi-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 18px 20px;
display: flex; align-items: center; gap: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.kpi-icon {
width: 52px; height: 52px; border-radius: 12px;
display: flex; align-items: center; justify-content: center; color: #fff;
width: 52px; height: 52px; border-radius: 12px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.green .kpi-icon { background: #67c23a; }
.blue .kpi-icon { background: #409eff; }
.orange .kpi-icon { background: #e6a23c; }
.purple .kpi-icon { background: #9c6ade; }
.kpi-value { font-size: 22px; font-weight: 700; color: #1f2d3d; }
.kpi-label { font-size: 12px; color: #909399; margin-top: 2px; }
.quick-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.quick-item {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 18px 10px; border-radius: 10px; cursor: pointer;
border: 1px solid #ebeef5; transition: all 0.2s;
.kpi-label { font-size: 13px; color: #909399; }
.kpi-value { font-size: 22px; font-weight: 700; color: #1f2d3d; margin-top: 2px; font-variant-numeric: tabular-nums; }
.kpi-foot { font-size: 12px; color: #c0c4cc; margin-top: 2px; min-height: 16px; }
.kpi-foot .up { color: #e03131; }
.kpi-foot .down { color: #12b76a; }
/* ---------- 图表网格 ---------- */
.dash-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
.panel {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.quick-item:hover { background: #f0f9f4; border-color: #3fae60; transform: translateY(-2px); }
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.panel-title {
font-size: 15px; font-weight: 600; color: #1f2d3d;
padding-left: 10px; border-left: 3px solid #1a73e8; line-height: 1.3;
}
.panel-extra { font-size: 12px; color: #a0a4ad; }
.chart { height: 282px; }
/* ---------- 表格 ---------- */
.mes-table { width: 100%; }
.mes-table :deep(.el-table__header th) { background: #f5f9ff; }
.mes-table :deep(.el-table__row:hover > td) { background: #f7faff !important; }
.status-tag {
display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px;
}
.status-tag.busy { background: #e8f0fe; color: #1a73e8; }
.status-tag.done { background: #f0f1f3; color: #909399; }
.gross { color: #1f2d3d; font-variant-numeric: tabular-nums; }
.empty { text-align: center; color: #aaa; padding: 30px 0; font-size: 13px; }
/* ---------- 快捷入口 ---------- */
.quick-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.quick-item {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px;
border: 1px solid #ebeef5; border-radius: 8px;
cursor: pointer; transition: all 0.2s;
}
.quick-item:hover {
border-color: #1a73e8; background: #f5f9ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(26, 115, 232, 0.12);
}
.quick-icon {
width: 40px; height: 40px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.quick-item span { font-size: 14px; color: #333; font-weight: 500; }
@media (max-width: 1200px) {
.kpi-row { grid-template-columns: repeat(2, 1fr); }
.dash-grid { grid-template-columns: 1fr; }
.quick-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
+15 -5
View File
@@ -3,7 +3,7 @@
<div class="login-bg"></div>
<el-card class="login-card">
<div class="brand">
<el-icon :size="44" color="#3fae60"><Apple /></el-icon>
<el-icon :size="44" color="#1a73e8"><Apple /></el-icon>
<div class="brand-name">农易富</div>
<div class="brand-sub">农产品收购交易平台</div>
</div>
@@ -14,7 +14,7 @@
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" :prefix-icon="Lock" show-password @keyup.enter="onSubmit" />
</el-form-item>
<el-button type="success" class="login-btn" size="large" :loading="loading" @click="onSubmit">
<el-button type="primary" class="login-btn" size="large" :loading="loading" @click="onSubmit">
</el-button>
</el-form>
@@ -31,7 +31,7 @@ import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { login } from '@/api'
import { login, getMyPermissions } from '@/api'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
@@ -50,6 +50,13 @@ const onSubmit = async () => {
try {
const res = await login(form)
auth.setLogin(res.token, res.user)
// 加载菜单与权限(失败不阻塞登录)
try {
const perms = await getMyPermissions()
auth.setPermissions(perms)
} catch {
auth.setPermissions(null)
}
ElMessage.success('登录成功')
const redirect = (route.query.redirect as string) || '/home'
router.push(redirect)
@@ -67,21 +74,24 @@ const onSubmit = async () => {
align-items: center;
justify-content: center;
overflow: hidden;
background: linear-gradient(135deg, #123724 0%, #2c7a45 50%, #123724 100%);
background: linear-gradient(135deg, #001529 0%, #1a73e8 50%, #001529 100%);
}
.login-bg {
position: absolute; inset: 0;
pointer-events: none;
background: radial-gradient(circle at 20% 30%, rgba(255,255,255,0.08), transparent 40%),
radial-gradient(circle at 80% 70%, rgba(255,255,255,0.08), transparent 40%);
}
.login-card {
position: relative;
z-index: 1;
width: 400px;
border-radius: 14px;
padding: 8px 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
}
.brand { text-align: center; margin: 8px 0 26px; }
.brand-name { font-size: 28px; font-weight: 700; color: #1f5135; letter-spacing: 3px; margin-top: 6px; }
.brand-name { font-size: 28px; font-weight: 700; color: #1f2d3d; letter-spacing: 3px; margin-top: 6px; }
.brand-sub { font-size: 13px; color: #888; margin-top: 4px; letter-spacing: 1px; }
.login-btn { width: 100%; margin-top: 6px; font-size: 16px; letter-spacing: 6px; }
.tips { margin-top: 18px; font-size: 12px; color: #999; text-align: center; line-height: 1.8; }
+47
View File
@@ -0,0 +1,47 @@
<template>
<div class="about-page">
<el-card shadow="never" class="about-card">
<div class="about-header">
<el-icon :size="52" color="#1a73e8"><Apple /></el-icon>
<h2>{{ info?.appName || '农易富农产品收购交易平台' }}</h2>
<div class="version">版本 {{ info?.version || '2.0.0' }}</div>
</div>
<el-descriptions :column="1" border class="about-desc">
<el-descriptions-item label="公司主体">{{ info?.company || '百博信息技术有限公司' }}</el-descriptions-item>
<el-descriptions-item label="授权信息">{{ info?.license || '企业版授权' }}</el-descriptions-item>
<el-descriptions-item label="产品说明">{{ info?.description }}</el-descriptions-item>
<el-descriptions-item label="编译日期">{{ fmt(info?.builtAt) }}</el-descriptions-item>
</el-descriptions>
<div class="about-footer">
{{ info?.copyright || '版权所有 © 2026 bbitcn.com' }}
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAbout } from '@/api'
import type { AboutInfo } from '@/types'
const info = ref<AboutInfo | null>(null)
const fmt = (s?: string) => (s ? new Date(s).toLocaleDateString() : '—')
onMounted(async () => {
try {
info.value = await getAbout()
} catch {
/* 后端不可用时显示默认值 */
}
})
</script>
<style scoped>
.about-page { display: flex; justify-content: center; padding: 24px 0; }
.about-card { width: 640px; }
.about-header { text-align: center; padding: 20px 0 24px; }
.about-header h2 { margin: 12px 0 6px; color: #1f2d3d; letter-spacing: 2px; }
.version { color: #909399; font-size: 13px; }
.about-desc { margin-top: 8px; }
.about-footer { margin-top: 24px; text-align: center; color: #909399; font-size: 12px; padding-bottom: 8px; }
</style>
+93 -14
View File
@@ -1,4 +1,4 @@
<template>
<template>
<el-card>
<div class="toolbar">
<div class="toolbar-left">
@@ -25,7 +25,7 @@
</el-select>
<el-button type="primary" @click="load(1)">查询</el-button>
</div>
<el-button type="success" @click="openEdit()">
<el-button type="primary" @click="openEdit()">
<el-icon><Plus /></el-icon>&nbsp;新增农户
</el-button>
</div>
@@ -115,7 +115,7 @@
<el-button type="primary" plain @click="openCameraDialog">
<el-icon><Camera /></el-icon>&nbsp;读取设备高拍仪
</el-button>
<el-button type="success" plain @click="openMobileDialog">
<el-button type="primary" plain @click="openMobileDialog">
<el-icon><Iphone /></el-icon>&nbsp;手机传图
</el-button>
<span class="idcard-tip">高拍仪拍摄 / 手机扫码上传身份证正反面OCR 自动识别姓名证件号性别住址</span>
@@ -595,23 +595,30 @@ const captureIdCard = async (side: 'front' | 'back') => {
const file = new File([blob], `idcard_${side}_${Date.now()}.jpg`, { type: 'image/jpeg' })
try {
const res = await uploadIdCard(file, side)
if (side === 'front') form.idCardFrontUrl = res.url
else form.idCardBackUrl = res.url
applyOcr(res.ocr)
ElMessage.success(`身份证${side === 'front' ? '正面' : '反面'}已上传` +
(res.ocr.success ? ',识别完成' : `${res.ocr.message}`))
if (side === 'front') {
form.idCardFrontUrl = res.url
await applyOcr(res.ocr)
ElMessage.success('身份证正面已上传' +
(res.ocr?.success ? ',识别完成' : (res.ocr?.message ? `${res.ocr.message}` : '')))
} else {
form.idCardBackUrl = res.url
ElMessage.success('身份证反面已上传(仅存档,不识别)')
}
} catch {
// HTTP 错误已由 request 拦截器统一提示
}
}
/** 将 OCR 结果填入表单 */
const applyOcr = (ocr?: IdCardOcrResult) => {
/** 将 OCR 结果填入表单;识别成功时自动拆分住址到省/区县/乡镇/村/组 */
const applyOcr = async (ocr?: IdCardOcrResult | null) => {
if (!ocr) return
if (ocr.name) form.name = ocr.name
if (ocr.idCard) form.idCard = ocr.idCard
if (ocr.gender) form.gender = ocr.gender
if (ocr.address) form.address = ocr.address
if (ocr.address) {
form.address = ocr.address
if (ocr.success) await splitAddress(ocr.address)
}
if (ocr.bankName) form.bankName = ocr.bankName
if (ocr.bankAccount) {
form.bankAccount = formatBankCardInput(ocr.bankAccount)
@@ -619,6 +626,76 @@ const applyOcr = (ocr?: IdCardOcrResult) => {
}
}
/** 在 text 中查找 list 里最先出现的项(返回名称与位置,用于地址逐级剥离) */
const earliestHit = (list: string[], text: string) => {
let best: { name: string; index: number } | null = null
for (const name of list) {
if (!name) continue
const i = text.indexOf(name)
if (i >= 0 && (!best || i < best.index)) best = { name, index: i }
}
return best
}
/**
* 将身份证 OCR 住址自动拆分到 省/区县/乡镇/村/组。
* 规则:街道与乡镇同级(归入"所在乡镇"字段);区县优先匹配数据库区县列表,
* 乡镇/村优先匹配已录数据,匹配不到时用正则从地址中提取(允许手工创建)。
*/
const splitAddress = async (address: string) => {
let rest = address.trim()
// 1. 省(自治区/直辖市):最长前缀匹配
const matchedProv = provinces.value
.filter(p => rest.startsWith(p))
.sort((a, b) => b.length - a.length)[0]
if (matchedProv) {
form.province = matchedProv
rest = rest.slice(matchedProv.length)
}
if (!form.province) return // 地址不含省(如"成都市...")时无法定位区县数据,其余留空供手工选择
// 2. 区县(含县级市):取县区列表中最先出现在地址里的
formCounties.value = await getCounties(form.province)
const countyHit = earliestHit(formCounties.value.map(c => c.name), rest)
if (countyHit) {
form.county = countyHit.name
rest = rest.slice(countyHit.index + countyHit.name.length)
formTownships.value = await getTownships(countyHit.name, cityOf(formCounties.value, countyHit.name))
if (!allVillages.value.length) allVillages.value = await getVillages(countyHit.name)
} else {
return
}
// 3. 乡镇 / 街道(同级):优先匹配公开乡镇列表,其次正则提取 XX镇/XX乡/XX街道
let townshipHit = earliestHit(formTownships.value, rest)
if (!townshipHit) {
const m = rest.match(/([^\u7701\u5e02\u533a\u53bf\u9547\u4e61\u8857\u9053]{1,12}?(?:镇|乡|街道|办事处))/)
if (m) townshipHit = { name: m[1], index: m.index! }
}
if (townshipHit) {
form.township = townshipHit.name
rest = rest.slice(townshipHit.index + townshipHit.name.length)
formVillages.value = await getVillages(form.county, townshipHit.name)
}
// 4. 村:优先匹配已录村数据,其次正则提取 XX村/XX居委会/XX社区
let villageHit = earliestHit(formVillages.value.map(v => v.name), rest)
if (!villageHit) {
const m = rest.match(/([^\u7701\u5e02\u533a\u53bf\u9547\u4e61\u8857\u9053]{1,12}?(?:村|居委会|社区))/)
if (m) villageHit = { name: m[1], index: m.index! }
}
if (villageHit) {
form.village = villageHit.name
rest = rest.slice(villageHit.index + villageHit.name.length)
if (form.township) formGroups.value = await getGroups(form.county, form.township, villageHit.name)
}
// 5. 组:正则提取"X组"(身份证住址一般不包含,能提取到则自动填入)
const groupMatch = rest.match(/([一二三四五六七八九十百\d]+组)/)
if (groupMatch) form.groupName = groupMatch[1]
}
// ---------- 手机传图(二维码) ----------
const mobileVisible = ref(false)
const qrDataUrl = ref('')
@@ -655,9 +732,11 @@ const startPolling = () => {
stopPolling()
if (res.frontUrl) form.idCardFrontUrl = res.frontUrl
if (res.backUrl) form.idCardBackUrl = res.backUrl
if (res.ocr) applyOcr(res.ocr)
mobileStatus.value = '上传成功!' + (res.ocr?.success ? '识别完成' : (res.ocr?.message || ''))
mobileStatusType.value = res.ocr?.success ? 'success' : 'error'
if (res.ocr) await applyOcr(res.ocr)
mobileStatus.value = '上传成功!' + (res.ocr
? (res.ocr.success ? '识别完成' : (res.ocr.message || ''))
: '(仅存档,不识别)')
mobileStatusType.value = res.ocr ? (res.ocr.success ? 'success' : 'error') : 'success'
}
} catch {
// 二维码失效(10 分钟)或网络异常:停止轮询并提示,可重新生成
+8 -8
View File
@@ -1,4 +1,4 @@
<template>
<template>
<el-card>
<!-- 开票统计过磅称重有效收购单为口径 -->
<div class="stat-cards">
@@ -61,7 +61,7 @@
<el-button type="warning" @click="openBatch">
<el-icon><Collection /></el-icon>&nbsp;批量开票
</el-button>
<el-button type="success" @click="openCreate()">
<el-button type="primary" @click="openCreate()">
<el-icon><Plus /></el-icon>&nbsp;开具销售发票
</el-button>
</div>
@@ -101,7 +101,7 @@
<template #default="{ row }">
<el-button v-if="row.invoiceStatus === 'None'" size="small" type="primary" @click="openCreate(row)">开票</el-button>
<el-button v-if="row.invoiceStatus === 'Pending'" size="small" type="warning" @click="onResult(row)">开票结果</el-button>
<el-button v-if="row.invoiceStatus === 'Issued'" size="small" type="success" @click="viewInvoice(row)">查看发票</el-button>
<el-button v-if="row.invoiceStatus === 'Issued'" size="small" type="primary" @click="viewInvoice(row)">查看发票</el-button>
<el-button v-if="row.invoiceStatus === 'Issued'" size="small" type="danger" @click="onReverse(row)">作废</el-button>
<el-button v-if="row.invoiceStatus === 'Failed' || row.invoiceStatus === 'Abnormal'" size="small" type="primary" @click="openCreate(row)">重新开票</el-button>
<el-button size="small" @click="viewDetail(row)">详情</el-button>
@@ -736,7 +736,7 @@ onBeforeUnmount(() => {
font-size: 22px; color: #fff; flex-shrink: 0;
}
.stat-icon.warning { background: linear-gradient(135deg, #f7ba2a, #e6a23c); }
.stat-icon.success { background: linear-gradient(135deg, #5acb7f, #67c23a); }
.stat-icon.success { background: linear-gradient(135deg, #5acb7f, #34a853); }
.stat-icon.danger { background: linear-gradient(135deg, #f56c6c, #e64340); }
.stat-icon.abnormal { background: linear-gradient(135deg, #909399, #a3a8b0); }
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.2; }
@@ -774,11 +774,11 @@ onBeforeUnmount(() => {
}
.p-cell b { display: block; font-size: 22px; font-weight: 700; margin-top: 2px; }
.p-cell.working b { color: #e6a23c; }
.p-cell.success b { color: #67c23a; }
.p-cell.success b { color: #34a853; }
.p-cell.danger b { color: #f56c6c; }
.p-cell.abnormal b { color: #909399; }
.progress-tip { margin-top: 12px; font-size: 13px; color: #e6a23c; }
.progress-tip.done { color: #67c23a; }
.progress-tip.done { color: #34a853; }
/* 最小化悬浮视窗 */
.mini-progress {
@@ -789,10 +789,10 @@ onBeforeUnmount(() => {
.mini-title { font-size: 13px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 4px; }
.mini-grid { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 8px; font-size: 12px; color: #606266; }
.mini-grid .working { color: #e6a23c; }
.mini-grid .success { color: #67c23a; }
.mini-grid .success { color: #34a853; }
.mini-grid .danger { color: #f56c6c; }
.mini-grid .abnormal { color: #909399; }
.mini-foot { margin-top: 6px; font-size: 12px; color: #67c23a; }
.mini-foot { margin-top: 6px; font-size: 12px; color: #34a853; }
.confirm-info {
display: flex; flex-direction: column; gap: 6px; padding: 12px 16px; margin-bottom: 14px;
@@ -0,0 +1,167 @@
<template>
<div class="announce-page">
<el-card shadow="never">
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索标题/内容" clearable style="width: 220px" @keyup.enter="load" />
<el-select v-model="type" placeholder="公告类型" clearable style="width: 140px" @change="load">
<el-option v-for="t in typeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<div class="spacer" />
<el-button type="primary" @click="openDialog()">发布公告</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column label="置顶" width="70" align="center">
<template #default="{ row }"><el-tag v-if="row.isPinned" size="small" type="danger"></el-tag></template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }"><el-tag size="small" :type="typeTag(row.type)">{{ typeLabel(row.type) }}</el-tag></template>
</el-table-column>
<el-table-column prop="publisherName" label="发布人" width="110" />
<el-table-column prop="publishedAt" label="发布时间" width="170">
<template #default="{ row }">{{ fmt(row.publishedAt) }}</template>
</el-table-column>
<el-table-column label="已读" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.isRead" size="small" type="success">已读</el-tag>
<el-tag v-else size="small" type="info">未读</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="view(row)">查看</el-button>
<el-button size="small" type="primary" link @click="openDialog(row)">编辑</el-button>
<el-button size="small" type="danger" link @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pager" background layout="total, prev, pager, next" :total="total"
v-model:current-page="page" :page-size="pageSize" @current-change="load" />
</el-card>
<el-dialog v-model="visible" :title="form.id ? '编辑公告' : '发布公告'" width="620px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="标题" required><el-input v-model="form.title" maxlength="100" show-word-limit /></el-form-item>
<el-form-item label="类型">
<el-select v-model="form.type" style="width: 200px">
<el-option v-for="t in typeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item label="置顶"><el-switch v-model="form.isPinned" /></el-form-item>
<el-form-item label="发布范围">
<el-input v-model="form.scopeOrgIds" placeholder="留空=全员可见;填写组织ID(逗号分隔),如:1,2" />
</el-form-item>
<el-form-item label="内容" required>
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="公告正文…" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">发布</el-button>
</template>
</el-dialog>
<el-dialog v-model="viewVisible" title="公告详情" width="600px">
<template v-if="viewing">
<h3 class="view-title">{{ viewing.title }}</h3>
<div class="view-meta">
<el-tag size="small" :type="typeTag(viewing.type)">{{ typeLabel(viewing.type) }}</el-tag>
<span>发布人{{ viewing.publisherName }}</span>
<span>时间{{ fmt(viewing.publishedAt) }}</span>
</div>
<div class="view-content">{{ viewing.content }}</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAnnouncements, saveAnnouncement, deleteAnnouncement, getAnnouncement } from '@/api'
import type { Announcement, DictCodeItem } from '@/types'
const keyword = ref('')
const type = ref('')
const list = ref<Announcement[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const saving = ref(false)
const visible = ref(false)
const viewVisible = ref(false)
const viewing = ref<Announcement | null>(null)
const typeOptions = ref<DictCodeItem[]>([
{ label: '通知', value: 'notice', ext: '', isDefault: true },
{ label: '公告', value: 'announce', ext: '', isDefault: false },
{ label: '价格行情', value: 'price', ext: '', isDefault: false },
{ label: '政策法规', value: 'policy', ext: '', isDefault: false }
])
const form = reactive<any>({ id: null, title: '', content: '', type: 'notice', scopeOrgIds: '', isPinned: false })
const typeLabel = (t: string) => typeOptions.value.find((o) => o.value === t)?.label || t
const typeTag = (t: string) => (t === 'announce' ? 'danger' : t === 'price' ? 'warning' : t === 'policy' ? 'info' : 'primary')
const fmt = (s: string) => (s ? new Date(s).toLocaleString() : '—')
const load = async () => {
loading.value = true
try {
const res = await getAnnouncements({ page: page.value, pageSize: pageSize.value, keyword: keyword.value || undefined, type: type.value || undefined })
list.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
const openDialog = (row?: Announcement) => {
if (row) {
Object.assign(form, { id: row.id, title: row.title, content: row.content, type: row.type, scopeOrgIds: row.scopeOrgIds, isPinned: row.isPinned })
} else {
Object.assign(form, { id: null, title: '', content: '', type: 'notice', scopeOrgIds: '', isPinned: false })
}
visible.value = true
}
const save = async () => {
if (!form.title || !form.content) {
ElMessage.warning('请填写标题和内容')
return
}
saving.value = true
try {
await saveAnnouncement({ ...form })
ElMessage.success('发布成功')
visible.value = false
load()
} finally {
saving.value = false
}
}
const view = async (row: Announcement) => {
viewing.value = await getAnnouncement(row.id)
viewVisible.value = true
load()
}
const remove = async (row: Announcement) => {
await ElMessageBox.confirm(`确定删除公告「${row.title}」吗?`, '提示', { type: 'warning' })
await deleteAnnouncement(row.id)
ElMessage.success('删除成功')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.spacer { flex: 1; }
.pager { margin-top: 14px; justify-content: flex-end; }
.view-title { margin: 0 0 10px; color: #1f2d3d; }
.view-meta { display: flex; align-items: center; gap: 16px; font-size: 12px; color: #909399; margin-bottom: 14px; }
.view-content { white-space: pre-wrap; line-height: 1.8; color: #333; max-height: 420px; overflow: auto; }
</style>
+2 -2
View File
@@ -1,4 +1,4 @@
<template>
<template>
<el-card>
<div class="toolbar">
<div class="toolbar-left">
@@ -10,7 +10,7 @@
<el-input v-model="keyword" placeholder="名称/联系人/电话" clearable style="width: 220px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
</div>
<el-button type="success" @click="openEdit()">
<el-button type="primary" @click="openEdit()">
<el-icon><Plus /></el-icon>&nbsp;新增组织
</el-button>
</div>
+4 -4
View File
@@ -1,4 +1,4 @@
<template>
<template>
<el-card>
<!-- 支付统计 -->
<div class="stat-cards">
@@ -47,7 +47,7 @@
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width: 250px" />
<el-button type="primary" @click="load(1)">查询</el-button>
</div>
<el-button type="success" @click="openCreate">
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>&nbsp;发起支付
</el-button>
</div>
@@ -76,7 +76,7 @@
<el-table-column prop="operatorName" label="经办人" width="90" />
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'Pending'" size="small" type="success" @click="onConfirm(row)">确认支付</el-button>
<el-button v-if="row.status === 'Pending'" size="small" type="primary" @click="onConfirm(row)">确认支付</el-button>
<el-button v-if="row.status === 'Success'" size="small" type="danger" @click="onRefund(row)">退款</el-button>
</template>
</el-table-column>
@@ -325,7 +325,7 @@ onMounted(() => {
font-size: 22px; color: #fff; flex-shrink: 0;
}
.stat-icon.warning { background: linear-gradient(135deg, #f7ba2a, #e6a23c); }
.stat-icon.success { background: linear-gradient(135deg, #5acb7f, #67c23a); }
.stat-icon.success { background: linear-gradient(135deg, #5acb7f, #34a853); }
.stat-icon.danger { background: linear-gradient(135deg, #f56c6c, #e64340); }
.stat-icon.abnormal { background: linear-gradient(135deg, #909399, #a3a8b0); }
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.2; }
+2 -2
View File
@@ -1,4 +1,4 @@
<template>
<template>
<el-card>
<div class="toolbar">
<div class="toolbar-left">
@@ -8,7 +8,7 @@
<el-input v-model="keyword" placeholder="品种名称" clearable style="width: 200px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
</div>
<el-button type="success" @click="openEdit()">
<el-button type="primary" @click="openEdit()">
<el-icon><Plus /></el-icon>&nbsp;新增品种
</el-button>
</div>
+3 -3
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="report">
<el-card>
<div class="toolbar">
@@ -102,7 +102,7 @@ const renderChart = () => {
yAxis: { type: 'value' },
series: [{
type: 'bar', data: rows.map((r) => Math.round(r.value)),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#3fae60' }, { offset: 1, color: '#cdeeda' }]), borderRadius: [4, 4, 0, 0] },
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#1a73e8' }, { offset: 1, color: '#cfe0fb' }]), borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top' }
}]
})
@@ -141,7 +141,7 @@ onBeforeUnmount(() => {
.cards { margin: 0 !important; }
.card-label { color: #909399; font-size: 13px; }
.card-value { font-size: 26px; font-weight: 700; margin-top: 6px; }
.green { color: #67c23a; } .blue { color: #409eff; } .orange { color: #e6a23c; }
.green { color: #34a853; } .blue { color: #409eff; } .orange { color: #e6a23c; }
.chart { height: 360px; }
.sub-table { margin-top: 16px; }
.sub-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 8px; }
+2 -2
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="report">
<el-card>
<div class="toolbar">
@@ -141,7 +141,7 @@ onBeforeUnmount(() => {
.cards { margin: 0 !important; }
.card-label { color: #909399; font-size: 13px; }
.card-value { font-size: 26px; font-weight: 700; margin-top: 6px; }
.green { color: #67c23a; } .blue { color: #409eff; } .orange { color: #e6a23c; }
.green { color: #34a853; } .blue { color: #409eff; } .orange { color: #e6a23c; }
.chart { height: 300px; }
.sub-table { margin-top: 16px; }
.sub-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 8px; }
@@ -1,4 +1,4 @@
<template>
<template>
<div class="report">
<el-card>
<div class="toolbar">
@@ -63,7 +63,7 @@
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { getPurchaseSummary } from '@/api'
import { getPurchaseSummaryReport } from '@/api'
import type { PurchaseSummaryResult } from '@/types'
const dimension = ref('day')
@@ -82,7 +82,7 @@ const defaultRange = () => {
const load = async () => {
const [from, to] = dateRange.value || defaultRange()
data.value = await getPurchaseSummary({ dimension: dimension.value, from, to })
data.value = await getPurchaseSummaryReport({ dimension: dimension.value, from, to })
await nextTick()
renderChart()
}
@@ -101,7 +101,7 @@ const renderChart = () => {
series: [{
type: isTime ? 'line' : 'bar', smooth: true,
data: sorted.map((r) => Math.round(r.value)),
itemStyle: { color: '#3fae60' },
itemStyle: { color: '#1a73e8' },
areaStyle: isTime ? { opacity: 0.15 } : undefined,
label: { show: !isTime, position: 'top' }
}]
@@ -143,6 +143,6 @@ onBeforeUnmount(() => {
.cards { margin: 0 !important; }
.card-label { color: #909399; font-size: 13px; }
.card-value { font-size: 26px; font-weight: 700; margin-top: 6px; }
.green { color: #67c23a; } .blue { color: #409eff; } .orange { color: #e6a23c; }
.green { color: #34a853; } .blue { color: #409eff; } .orange { color: #e6a23c; }
.chart { height: 380px; }
</style>
+237
View File
@@ -0,0 +1,237 @@
<template>
<div class="dict-page">
<el-card shadow="never" class="filter-card">
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索字典名称/编码" clearable style="width: 260px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
<div class="spacer" />
<el-button type="primary" @click="openDictDialog()">新增字典</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="字典名称" min-width="140" />
<el-table-column prop="code" label="字典编码" min-width="160">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.code }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="itemCount" label="字典项" width="80" align="center" />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="内置" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.isSystem" size="small" type="info">系统</el-tag>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="openDictDialog(row)">编辑</el-button>
<el-button size="small" type="primary" link @click="openItems(row)">字典项</el-button>
<el-button size="small" type="danger" link :disabled="row.isSystem" @click="removeDict(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pager" background layout="total, prev, pager, next, sizes"
:total="total" v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" @current-change="load" @size-change="load"
/>
</el-card>
<!-- 字典类型编辑 -->
<el-dialog v-model="dictVisible" :title="dictForm.id ? '编辑字典' : '新增字典'" width="460px" destroy-on-close>
<el-form :model="dictForm" label-width="80px">
<el-form-item label="字典名称" required>
<el-input v-model="dictForm.name" placeholder="如:收购等级" />
</el-form-item>
<el-form-item label="字典编码" required>
<el-input v-model="dictForm.code" placeholder="如:purchase_grade(小写字母/下划线)" :disabled="dictForm.isSystem" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="dictForm.remark" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="dictForm.sort" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dictVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitDict">保存</el-button>
</template>
</el-dialog>
<!-- 字典项管理 -->
<el-dialog v-model="itemsVisible" :title="`字典项:${currentDict?.name} (${currentDict?.code})`" width="640px" destroy-on-close>
<div class="toolbar item-toolbar">
<el-button size="small" type="primary" @click="openItemDialog()">新增字典项</el-button>
</div>
<el-table :data="items" size="small" stripe>
<el-table-column prop="label" label="显示名称" min-width="110" />
<el-table-column prop="value" label="值" min-width="90" />
<el-table-column prop="ext" label="扩展" min-width="130" show-overflow-tooltip />
<el-table-column label="默认" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.isDefault" size="small" type="success">默认</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="60" align="center">
<template #default="{ row }">
<el-icon v-if="row.enabled" color="#67c23a"><Check /></el-icon>
<el-icon v-else color="#c0c4cc"><Close /></el-icon>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="openItemDialog(row)">编辑</el-button>
<el-button size="small" type="danger" link @click="removeItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- 字典项编辑 -->
<el-dialog v-model="itemVisible" :title="itemForm.id ? '编辑字典项' : '新增字典项'" width="460px" destroy-on-close>
<el-form :model="itemForm" label-width="80px">
<el-form-item label="显示名称" required>
<el-input v-model="itemForm.label" placeholder="如:一等" />
</el-form-item>
<el-form-item label="值" required>
<el-input v-model="itemForm.value" placeholder="如:1" />
</el-form-item>
<el-form-item label="扩展">
<el-input v-model="itemForm.ext" placeholder="扩展属性(如基准价/纸张规格)" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="itemForm.sort" :min="0" />
</el-form-item>
<el-form-item label="默认项">
<el-switch v-model="itemForm.isDefault" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="itemForm.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="itemVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveItem">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getDicts, getDictDetail, saveDict, deleteDict, saveDictItem, deleteDictItem } from '@/api'
import type { Dict, DictItemDto } from '@/types'
const keyword = ref('')
const list = ref<Dict[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const dictVisible = ref(false)
const itemsVisible = ref(false)
const itemVisible = ref(false)
const saving = ref(false)
const currentDict = ref<Dict | null>(null)
const items = ref<DictItemDto[]>([])
const dictForm = reactive<any>({ id: null, name: '', code: '', remark: '', sort: 0, isSystem: false })
const itemForm = reactive<any>({ id: null, label: '', value: '', ext: '', sort: 0, isDefault: false, enabled: true })
const load = async () => {
loading.value = true
try {
const res = await getDicts({ page: page.value, pageSize: pageSize.value, keyword: keyword.value || undefined })
list.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
const openDictDialog = (row?: Dict) => {
if (row) {
Object.assign(dictForm, { id: row.id, name: row.name, code: row.code, remark: row.remark, sort: row.sort, isSystem: row.isSystem })
} else {
Object.assign(dictForm, { id: null, name: '', code: '', remark: '', sort: 0, isSystem: false })
}
dictVisible.value = true
}
const submitDict = async () => {
if (!dictForm.name || !dictForm.code) {
ElMessage.warning('请填写字典名称和编码')
return
}
saving.value = true
try {
await saveDict({ ...dictForm })
ElMessage.success('保存成功')
dictVisible.value = false
load()
} finally {
saving.value = false
}
}
const removeDict = async (row: Dict) => {
await ElMessageBox.confirm(`确定删除字典「${row.name}」及其全部字典项吗?`, '提示', { type: 'warning' })
await deleteDict(row.id)
ElMessage.success('删除成功')
load()
}
const openItems = async (row: Dict) => {
currentDict.value = row
const res = await getDictDetail(row.id)
items.value = res.items
itemsVisible.value = true
}
const openItemDialog = (row?: DictItemDto) => {
if (row) {
Object.assign(itemForm, { ...row })
} else {
Object.assign(itemForm, { id: null, label: '', value: '', ext: '', sort: items.value.length, isDefault: false, enabled: true })
}
itemVisible.value = true
}
const saveItem = async () => {
if (!currentDict.value) return
if (!itemForm.label || !itemForm.value) {
ElMessage.warning('请填写显示名称和值')
return
}
saving.value = true
try {
await saveDictItem(currentDict.value.id, { ...itemForm })
ElMessage.success('保存成功')
itemVisible.value = false
openItems(currentDict.value)
} finally {
saving.value = false
}
}
const removeItem = async (row: DictItemDto) => {
await ElMessageBox.confirm(`确定删除字典项「${row.label}」吗?`, '提示', { type: 'warning' })
await deleteDictItem(row.id!)
ElMessage.success('删除成功')
openItems(currentDict.value!)
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.spacer { flex: 1; }
.item-toolbar { margin-bottom: 10px; }
.pager { margin-top: 14px; justify-content: flex-end; }
</style>
+135
View File
@@ -0,0 +1,135 @@
<template>
<div class="menu-page">
<el-card shadow="never">
<div class="toolbar">
<span class="tip">菜单即权限资源目录directory分组菜单menu对应页面按钮button对应页面内操作权限</span>
<div class="spacer" />
<el-button type="primary" @click="openDialog()">新增根菜单</el-button>
</div>
<el-table :data="list" row-key="id" v-loading="loading" stripe default-expand-all :tree-props="{ children: 'children' }">
<el-table-column prop="name" label="菜单名称" min-width="170" />
<el-table-column prop="path" label="路由路径" min-width="140">
<template #default="{ row }"><span class="mono">{{ row.path || '—' }}</span></template>
</el-table-column>
<el-table-column prop="permission" label="权限标识" min-width="170">
<template #default="{ row }">
<el-tag v-if="row.permission" size="small" type="warning" effect="plain">{{ row.permission }}</el-tag>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.type === 'directory' ? 'info' : row.type === 'button' ? 'success' : 'primary'">{{ typeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="icon" label="图标" width="80">
<template #default="{ row }">
<el-icon v-if="row.icon"><component :is="row.icon" /></el-icon><span v-else></span>
</template>
</el-table-column>
<el-table-column label="可见" width="70" align="center">
<template #default="{ row }">
<el-icon v-if="row.visible" color="#67c23a"><Check /></el-icon>
<el-icon v-else color="#c0c4cc"><Close /></el-icon>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="openDialog(row)">编辑</el-button>
<el-button size="small" type="primary" link @click="openDialog(undefined, row.id)">新增子项</el-button>
<el-button size="small" type="danger" link @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="visible" :title="form.id ? '编辑菜单' : '新增菜单'" width="560px" destroy-on-close>
<el-form :model="form" label-width="90px">
<el-form-item label="菜单名称" required><el-input v-model="form.name" /></el-form-item>
<el-form-item label="上级菜单">
<el-tree-select v-model="form.parentId" :data="parentOptions" node-key="id" check-strictly clearable
:props="{ label: 'name', children: 'children' }" default-expand-all placeholder="留空为根菜单" style="width: 100%" />
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="form.type">
<el-radio-button label="directory">目录</el-radio-button>
<el-radio-button label="menu">菜单</el-radio-button>
<el-radio-button label="button">按钮</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="路由路径"><el-input v-model="form.path" placeholder="如:/system/dicts" /></el-form-item>
<el-form-item label="组件路径"><el-input v-model="form.component" placeholder="如:views/system/DictList.vue" /></el-form-item>
<el-form-item label="权限标识"><el-input v-model="form.permission" placeholder="如:system:dict:list" /></el-form-item>
<el-form-item label="图标"><el-input v-model="form.icon" placeholder="Element Plus 图标名,如:Setting" /></el-form-item>
<el-form-item label="可见"><el-switch v-model="form.visible" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sort" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMenus, saveMenu, deleteMenu } from '@/api'
import type { SysMenu } from '@/types'
const list = ref<SysMenu[]>([])
const loading = ref(false)
const saving = ref(false)
const visible = ref(false)
const form = reactive<any>({ id: null, parentId: null, name: '', path: '', component: '', icon: '', type: 'menu', permission: '', visible: true, sort: 0 })
const parentOptions = computed(() => {
const walk = (items: SysMenu[]): any[] =>
items.filter((m) => m.type !== 'button' && m.id !== form.id).map((m) => ({ id: m.id, name: m.name, children: walk(m.children || []) }))
return walk(list.value)
})
const typeLabel = (t: string) => ({ directory: '目录', menu: '菜单', button: '按钮' }[t] || t)
const load = async () => {
loading.value = true
try { list.value = await getMenus() } finally { loading.value = false }
}
const openDialog = (row?: SysMenu, parentId?: number | null) => {
if (row) Object.assign(form, { id: row.id, parentId: row.parentId, name: row.name, path: row.path, component: row.component, icon: row.icon, type: row.type, permission: row.permission, visible: row.visible, sort: row.sort })
else Object.assign(form, { id: null, parentId: parentId ?? null, name: '', path: '', component: '', icon: '', type: 'menu', permission: '', visible: true, sort: 0 })
visible.value = true
}
const save = async () => {
if (!form.name) { ElMessage.warning('请填写菜单名称'); return }
saving.value = true
try {
await saveMenu({ ...form })
ElMessage.success('保存成功')
visible.value = false
load()
} finally {
saving.value = false
}
}
const remove = async (row: SysMenu) => {
await ElMessageBox.confirm(`确定删除菜单「${row.name}」吗?`, '提示', { type: 'warning' })
await deleteMenu(row.id)
ElMessage.success('删除成功')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.tip { font-size: 12px; color: #909399; }
.spacer { flex: 1; }
.mono { font-family: Consolas, monospace; font-size: 12px; }
</style>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="role-page">
<el-card shadow="never">
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索角色名称/编码" clearable style="width: 240px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
<div class="spacer" />
<el-button type="primary" @click="openDialog()">新增角色</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="角色名称" min-width="130" />
<el-table-column prop="code" label="角色编码" min-width="140">
<template #default="{ row }"><el-tag size="small" effect="plain">{{ row.code }}</el-tag></template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="内置" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.isSystem" size="small" type="info">系统</el-tag><span v-else></span>
</template>
</el-table-column>
<el-table-column label="菜单数" width="80" align="center">
<template #default="{ row }">{{ row.menuIds?.length ?? 0 }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="openDialog(row)">编辑</el-button>
<el-button size="small" type="danger" link :disabled="row.isSystem" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pager" background layout="total, prev, pager, next" :total="total"
v-model:current-page="page" :page-size="pageSize" @current-change="load" />
</el-card>
<el-dialog v-model="visible" :title="form.id ? '编辑角色' : '新增角色'" width="620px" destroy-on-close>
<el-form :model="form" label-width="90px">
<el-form-item label="角色名称" required><el-input v-model="form.name" placeholder="如:收购站主管" /></el-form-item>
<el-form-item label="角色编码" required><el-input v-model="form.code" placeholder="如:station_manager" :disabled="form.isSystem" /></el-form-item>
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="菜单权限">
<el-tree ref="treeRef" :data="menuTree" show-checkbox node-key="id" default-expand-all
:props="{ label: 'name', children: 'children' }"
style="width: 100%; border: 1px solid #ebeef5; border-radius: 6px; padding: 8px; max-height: 320px; overflow: auto;" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { ElTree } from 'element-plus'
import { getRoles, saveRole, deleteRole, getMenus } from '@/api'
import type { SysRole, SysMenu } from '@/types'
const keyword = ref('')
const list = ref<SysRole[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const saving = ref(false)
const visible = ref(false)
const menuTree = ref<SysMenu[]>([])
const treeRef = ref<InstanceType<typeof ElTree>>()
const form = reactive<any>({ id: null, name: '', code: '', description: '', isSystem: false })
const load = async () => {
loading.value = true
try {
const res = await getRoles({ page: page.value, pageSize: pageSize.value, keyword: keyword.value || undefined })
list.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
const openDialog = async (row?: SysRole) => {
if (!menuTree.value.length) {
menuTree.value = await getMenus()
}
if (row) {
Object.assign(form, { id: row.id, name: row.name, code: row.code, description: row.description, isSystem: row.isSystem })
} else {
Object.assign(form, { id: null, name: '', code: '', description: '', isSystem: false })
}
visible.value = true
await nextTick()
// 回显已选菜单(含半选父级)
const checked = row?.menuIds ?? []
if (row && treeRef.value) {
treeRef.value.setCheckedKeys(checked)
} else if (treeRef.value) {
treeRef.value.setCheckedKeys([])
}
}
const collectMenuIds = (): number[] => {
if (!treeRef.value) return []
return (treeRef.value as any).getCheckedKeys(false).concat((treeRef.value as any).getHalfCheckedKeys())
}
const save = async () => {
if (!form.name || !form.code) {
ElMessage.warning('请填写角色名称和编码')
return
}
saving.value = true
try {
await saveRole({ ...form, menuIds: collectMenuIds() })
ElMessage.success('保存成功')
visible.value = false
load()
} finally {
saving.value = false
}
}
const remove = async (row: SysRole) => {
await ElMessageBox.confirm(`确定删除角色「${row.name}」吗?`, '提示', { type: 'warning' })
await deleteRole(row.id)
ElMessage.success('删除成功')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.spacer { flex: 1; }
.pager { margin-top: 14px; justify-content: flex-end; }
</style>
+2 -2
View File
@@ -1,4 +1,4 @@
<template>
<template>
<el-card>
<div class="toolbar">
<div class="toolbar-left">
@@ -8,7 +8,7 @@
<el-input v-model="query.keyword" placeholder="用户名/姓名/电话" clearable style="width: 220px" @keyup.enter="load(1)" />
<el-button type="primary" @click="load(1)">查询</el-button>
</div>
<el-button type="success" @click="openEdit()">
<el-button type="primary" @click="openEdit()">
<el-icon><Plus /></el-icon>&nbsp;新增用户
</el-button>
</div>
+729 -139
View File
@@ -1,122 +1,271 @@
<template>
<el-card>
<div class="toolbar">
<div class="toolbar-left">
<el-select v-model="query.status" placeholder="状态" clearable style="width: 120px">
<el-option label="过磅中" value="Weighing" />
<el-option label="已完成" value="Completed" />
<el-option label="已作废" value="Cancelled" />
</el-select>
<el-select v-model="query.productId" placeholder="品种" clearable filterable style="width: 140px">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width: 250px" />
<el-input v-model="query.keyword" placeholder="单号/农户/电话" clearable style="width: 200px" @keyup.enter="load(1)" />
<el-button type="primary" @click="load(1)">查询</el-button>
<template>
<div class="weighing-page">
<!-- 3.1 多维统计卡片 -->
<el-row :gutter="12" class="stat-row">
<el-col :span="4"><div class="stat-card"><div class="stat-num">{{ summary.orderCount }}</div><div class="stat-label">收购单数</div></div></el-col>
<el-col :span="4"><div class="stat-card"><div class="stat-num">{{ summary.totalNetWeight.toLocaleString() }}</div><div class="stat-label">重量合计{{ unitLabel }}</div></div></el-col>
<el-col :span="4"><div class="stat-card"><div class="stat-num">{{ fmtMoney(summary.avgPrice) }}</div><div class="stat-label">均价/{{ unitLabel }}</div></div></el-col>
<el-col :span="4"><div class="stat-card"><div class="stat-num">¥ {{ fmtMoney(summary.totalAmount) }}</div><div class="stat-label">金额合计</div></div></el-col>
<el-col :span="4"><div class="stat-card"><div class="stat-num">{{ summary.farmerCount }}</div><div class="stat-label">合作农户</div></div></el-col>
<el-col :span="4"><div class="stat-card"><div class="stat-num">{{ summary.todayOrderCount }}</div><div class="stat-label">今日单数</div></div></el-col>
</el-row>
<el-card>
<div class="toolbar">
<div class="toolbar-left">
<el-select v-model="query.status" placeholder="状态" clearable style="width: 120px">
<el-option label="过磅中" value="Weighing" />
<el-option label="已完成" value="Completed" />
<el-option label="已作废" value="Cancelled" />
</el-select>
<el-select v-model="query.productId" placeholder="品种" clearable filterable style="width: 140px">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width: 250px" />
<el-input v-model="query.keyword" placeholder="单号/农户/电话" clearable style="width: 200px" @keyup.enter="load(1)" />
<el-button type="primary" @click="load(1)">查询</el-button>
</div>
<div>
<el-button type="warning" @click="$router.push('/weighing/touch')">
<el-icon><Iphone /></el-icon>&nbsp;触摸屏过磅
</el-button>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>&nbsp;新建收购单
</el-button>
</div>
</div>
<div>
<el-button type="warning" @click="$router.push('/weighing/touch')">
<el-icon><Iphone /></el-icon>&nbsp;触摸屏过磅
</el-button>
<el-button type="success" @click="openCreate">
<el-icon><Plus /></el-icon>&nbsp;新建收购单
</el-button>
</div>
</div>
<el-table :data="rows" v-loading="loading" border stripe>
<el-table-column prop="orderNo" label="收购单号" width="160" fixed />
<el-table-column prop="farmerName" label="农户" width="100" />
<el-table-column prop="productName" label="品种" width="90" />
<el-table-column prop="purchaserOrgName" label="收购方" width="150" show-overflow-tooltip />
<el-table-column prop="grade" label="等级" width="70" align="center" />
<el-table-column prop="grossWeight" label="毛重(kg)" width="100" align="right" />
<el-table-column prop="tareWeight" label="皮重(kg)" width="100" align="right" />
<el-table-column prop="netWeight" label="净重(kg)" width="100" align="right">
<template #default="{ row }">{{ row.netWeight || '-' }}</template>
</el-table-column>
<el-table-column prop="unitPrice" label="单价" width="90" align="right">
<template #default="{ row }">{{ row.unitPrice ? row.unitPrice.toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column prop="amount" label="金额(元)" width="120" align="right">
<template #default="{ row }">
<b v-if="row.amount">{{ row.amount.toLocaleString() }}</b>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="过磅时间" width="160">
<template #default="{ row }">{{ dayjs(row.weighInAt).format('MM-DD HH:mm') }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'Weighing'" size="small" type="success" @click="openTare(row)">回皮</el-button>
<el-button v-if="row.status !== 'Cancelled'" size="small" type="primary" @click="openPrice(row)">调价</el-button>
<el-button size="small" @click="viewDetail(row)">详情</el-button>
<el-button v-if="row.status !== 'Cancelled'" size="small" type="danger" @click="onCancel(row)">作废</el-button>
</template>
</el-table-column>
</el-table>
<el-table :data="rows" v-loading="loading" border stripe>
<el-table-column prop="orderNo" label="收购单号" width="160" fixed />
<el-table-column prop="farmerName" label="农户" width="100" />
<el-table-column prop="productName" label="品种" width="90" />
<el-table-column prop="purchaserOrgName" label="收购方" width="140" show-overflow-tooltip />
<el-table-column prop="grade" label="等级" width="70" align="center" />
<el-table-column prop="boxCount" label="框数" width="70" align="center">
<template #default="{ row }">{{ row.boxCount || '-' }}</template>
</el-table-column>
<el-table-column prop="netWeight" label="净重(kg)" width="100" align="right">
<template #default="{ row }">{{ row.netWeight || '-' }}</template>
</el-table-column>
<el-table-column prop="unitPrice" label="均价" width="90" align="right">
<template #default="{ row }">{{ row.unitPrice ? row.unitPrice.toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column prop="amount" label="金额(元)" width="120" align="right">
<template #default="{ row }">
<b v-if="row.amount">{{ row.amount.toLocaleString() }}</b>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="过磅时间" width="160">
<template #default="{ row }">{{ dayjs(row.weighInAt).format('MM-DD HH:mm') }}</template>
</el-table-column>
<!-- 3.4 更多按钮 -->
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">详情</el-button>
<el-dropdown trigger="click" @command="(c: string) => onMore(c, row)">
<el-button size="small">
更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="reprint" :disabled="row.status === 'Cancelled'">补打票据</el-dropdown-item>
<el-dropdown-item command="transfer" :disabled="row.status === 'Cancelled'">票据过户</el-dropdown-item>
<el-dropdown-item command="tare" v-if="row.status === 'Weighing'" divided>回皮</el-dropdown-item>
<el-dropdown-item command="price" v-if="row.status !== 'Cancelled'">调价</el-dropdown-item>
<el-dropdown-item command="cancel" v-if="row.status !== 'Cancelled'" divided>作废</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pager"
background layout="total, sizes, prev, pager, next"
:total="total" :page-sizes="[10, 20, 50, 100]"
:current-page="query.page" :page-size="query.pageSize"
@current-change="load" @size-change="onSizeChange"
/>
<el-pagination
class="pager"
background layout="total, sizes, prev, pager, next"
:total="total" :page-sizes="[10, 20, 50, 100]"
:current-page="query.page" :page-size="query.pageSize"
@current-change="load" @size-change="onSizeChange"
/>
</el-card>
<!-- 新建收购单毛重 -->
<el-dialog v-model="createVisible" title="新建收购单 · 第一次过磅(称毛重)" width="620px" destroy-on-close>
<el-form :model="createForm" label-width="90px">
<el-form-item label="农户" required>
<el-select v-model="createForm.farmerId" filterable remote :remote-method="searchFarmers" placeholder="输入姓名/手机号搜索" style="width: 100%">
<el-option v-for="f in farmerOptions" :key="f.id" :label="`${f.name}${f.phone}${f.village}`" :value="f.id" />
</el-select>
</el-form-item>
<el-form-item label="收购品种" required>
<el-select v-model="createForm.productId" placeholder="选择品种" style="width: 100%">
<el-option v-for="p in products" :key="p.id" :label="`${p.name}${p.category},参考价 ${p.price} 元/${p.unit}`" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="收购方" required>
<el-select v-model="createForm.purchaserOrgId" placeholder="选择公司/站点/个体" style="width: 100%">
<el-option v-for="o in orgs" :key="o.id" :label="`${orgTypeLabel(o.type)}·${o.name}`" :value="o.id" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="等级">
<el-select v-model="createForm.grade" style="width: 100%">
<el-option label="一等" value="一等" /><el-option label="二等" value="二等" /><el-option label="三等" value="三等" />
<!-- 3.2 新建收购单多磅录入左右结构 -->
<el-dialog v-model="createVisible" title="新建收购单 · 多磅录入" width="1180px" destroy-on-close
:close-on-click-modal="false" :close-on-press-escape="false">
<div class="create-layout">
<!-- 左侧农户信息 -->
<div class="create-left">
<div class="panel-title">农户信息</div>
<el-input v-model="farmerKeyword" placeholder="姓名 / 拼音首字母 / 身份证 / 手机号" clearable @input="debounceSearchFarmer" @keyup.enter="searchFarmer">
<template #suffix><el-icon><Search /></el-icon></template>
</el-input>
<div v-if="farmerOptions.length" class="farmer-list">
<div v-for="f in farmerOptions" :key="f.id" class="farmer-item" :class="{ active: createForm.farmerId === f.id }" @click="selectFarmer(f)">
<div class="farmer-name">{{ f.name }} <span class="farmer-py">{{ f.pinyinInitials }}</span></div>
<div class="farmer-sub">{{ f.phone }} · {{ f.village || f.county }}</div>
</div>
</div>
<div v-else class="farmer-empty">输入关键字搜索农户支持拼音首字母</div>
<el-divider style="margin: 10px 0">基本信息</el-divider>
<el-descriptions v-if="selectedFarmer" :column="1" border size="small">
<el-descriptions-item label="姓名">{{ selectedFarmer.name }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ selectedFarmer.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证">{{ selectedFarmer.idCard || '-' }}</el-descriptions-item>
<el-descriptions-item label="地区">{{ [selectedFarmer.province, selectedFarmer.county, selectedFarmer.township, selectedFarmer.village].filter(Boolean).join(' ') || '-' }}</el-descriptions-item>
<el-descriptions-item label="村组">{{ selectedFarmer.groupName || '-' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="请先选择农户" :image-size="60" />
<el-form label-width="70px" class="create-form" style="margin-top: 10px">
<el-form-item label="收购品种" required>
<el-select v-model="createForm.productId" placeholder="选择品种" style="width: 100%" @change="onProductChange">
<el-option v-for="p in products" :key="p.id" :label="`${p.name}${p.category},参考价 ${p.price} 元/${p.unit}`" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="单价(元/kg)">
<el-input-number v-model="createForm.unitPrice" :min="0" :precision="2" :step="0.1" style="width: 100%" />
<el-form-item label="收购方" required>
<el-select v-model="createForm.purchaserOrgId" placeholder="选择公司/站点/个体" style="width: 100%">
<el-option v-for="o in orgs" :key="o.id" :label="`${orgTypeLabel(o.type)}·${o.name}`" :value="o.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="毛重(kg)" required>
<el-input-number v-model="createForm.grossWeight" :min="0" :precision="2" style="width: 100%" />
<el-form-item label="备注">
<el-input v-model="createForm.notes" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注"><el-input v-model="createForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
</el-form>
</div>
<!-- 右侧电子秤显示区 + 操作区 -->
<div class="create-right">
<!-- 右上电子秤显示屏 -->
<div class="scale-panel">
<div class="scale-head">
<span class="panel-title">电子秤{{ scaleLabel }}</span>
<div>
<el-button size="small" :type="scaleConnected ? 'danger' : 'primary'" :loading="scaleConnecting" @click="toggleScale">
{{ scaleConnected ? '断开串口' : '连接电子秤' }}
</el-button>
<el-button size="small" @click="manualScale = !manualScale">手动模式</el-button>
</div>
</div>
<div class="scale-display">
<div class="scale-weight">{{ scaleWeight.toFixed(2) }}</div>
<div class="scale-unit">kg</div>
<div class="scale-status">
<el-tag :type="scaleConnected ? 'success' : 'info'" size="small">{{ scaleStatus }}</el-tag>
</div>
<div v-if="manualScale" class="scale-manual">
<el-input-number v-model="manualWeight" :min="0" :precision="2" :step="0.1" style="width: 160px" />
<el-button size="small" type="primary" style="margin-left: 8px" @click="applyScaleWeight">应用到当前磅</el-button>
</div>
</div>
</div>
<!-- 右下多磅操作区 -->
<div class="weigh-panel">
<div class="panel-title">过磅明细一单多磅 · 按等级计价</div>
<el-table :data="weighRows" border size="small" max-height="260">
<el-table-column label="#" width="44" align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="等级" width="86">
<template #default="{ row }">
<el-select v-model="row.grade" size="small" placeholder="等级">
<el-option v-for="g in gradeDict" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="计价方式" width="104">
<template #default="{ row }">
<el-select v-model="row.priceType" size="small">
<el-option v-for="t in priceTypeDict" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</template>
</el-table-column>
<el-table-column :label="priceColLabel" width="150">
<template #default="{ row }">
<div v-if="row.priceType === 'Range'" style="display: flex; align-items: center; gap: 4px">
<el-input-number v-model="row.priceMin" size="small" :min="0" :precision="2" :controls="false" style="width: 66px" placeholder="低" />
<span>-</span>
<el-input-number v-model="row.priceMax" size="small" :min="0" :precision="2" :controls="false" style="width: 66px" placeholder="高" />
</div>
<el-input-number v-else v-model="row.unitPrice" size="small" :min="0" :precision="2" :step="0.1" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="毛重(kg)" width="104">
<template #default="{ row }">
<el-input-number v-model="row.grossWeight" size="small" :min="0" :precision="2" :step="0.05" style="width: 100%" @change="applyGross(row)" />
</template>
</el-table-column>
<el-table-column label="皮重(kg)" width="104">
<template #default="{ row }">
<el-input-number v-model="row.tareWeight" size="small" :min="0" :precision="2" :step="0.05" style="width: 100%" @change="recalcRow(row)" />
</template>
</el-table-column>
<el-table-column label="框数" width="84">
<template #default="{ row }">
<el-input-number v-model="row.boxCount" size="small" :min="0" :precision="0" style="width: 100%" @change="recalcRow(row)" />
</template>
</el-table-column>
<el-table-column label="净重(kg)" width="90" align="right">
<template #default="{ row }"><b>{{ row.netWeight.toFixed(2) }}</b></template>
</el-table-column>
<el-table-column label="金额(元)" width="110" align="right">
<template #default="{ row }">{{ fmtMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="" width="52" align="center">
<template #default="{ $index }">
<el-button v-if="weighRows.length > 1" size="small" type="danger" text @click="removeWeigh($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div class="weigh-toolbar">
<el-button size="small" type="primary" plain @click="addWeigh">
<el-icon><Plus /></el-icon>&nbsp;添加一磅
</el-button>
<span class="weigh-tip">皮重为容器重量框数为容器个数</span>
</div>
<!-- 3.2.5 按等级汇总 -->
<div v-if="gradeSummaryRows.length" class="grade-summary">
<div class="panel-title">按等级汇总</div>
<el-table :data="gradeSummaryRows" border size="small">
<el-table-column prop="grade" label="等级" width="100" />
<el-table-column prop="net" label="净重合计(kg)" align="right">
<template #default="{ row }">{{ row.net.toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="amount" label="金额合计(元)" align="right">
<template #default="{ row }">{{ fmtMoney(row.amount) }}</template>
</el-table-column>
</el-table>
<div class="grade-total">
<span>合计净重<b>{{ totalNet.toFixed(2) }} kg</b></span>
<span>合计框数<b>{{ totalBox }} </b></span>
<span>合计金额<b>¥ {{ fmtMoney(totalAmount) }}</b></span>
<span>均价<b>¥ {{ totalNet > 0 ? (totalAmount / totalNet).toFixed(2) : '0.00' }} /kg</b></span>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onCreate">保存并生成收购单</el-button>
<el-button type="primary" :loading="saving" @click="onCreate">
<el-icon><Printer /></el-icon>&nbsp;保存并打印小票
</el-button>
</template>
</el-dialog>
<!-- 回皮 -->
<el-dialog v-model="tareVisible" title="回皮 · 第二次过磅(称皮重)" width="480px" destroy-on-close>
<el-dialog v-model="tareVisible" title="回皮 · 第二次过磅(称皮重)" width="480px" destroy-on-close :close-on-click-modal="false">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="收购单号">{{ tareOrder?.orderNo }}</el-descriptions-item>
<el-descriptions-item label="农户">{{ tareOrder?.farmerName }}</el-descriptions-item>
@@ -148,7 +297,7 @@
</el-form-item>
<el-form-item label="等级">
<el-select v-model="priceForm.grade" style="width: 100%">
<el-option label="一等" value="一等" /><el-option label="二等" value="二等" /><el-option label="三等" value="三等" />
<el-option v-for="g in gradeDict" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
</el-form-item>
</el-form>
@@ -158,51 +307,127 @@
</template>
</el-dialog>
<!-- 详情 -->
<el-dialog v-model="detailVisible" title="收购单详情" width="620px">
<el-descriptions v-if="detail" :column="2" border>
<el-descriptions-item label="收购单号">{{ detail.orderNo }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(detail.status) }}</el-descriptions-item>
<el-descriptions-item label="农户">{{ detail.farmerName }}</el-descriptions-item>
<el-descriptions-item label="农户电话">{{ detail.farmerPhone || '-' }}</el-descriptions-item>
<el-descriptions-item label="品种">{{ detail.productName }}{{ detail.category }}</el-descriptions-item>
<el-descriptions-item label="等级">{{ detail.grade || '-' }}</el-descriptions-item>
<el-descriptions-item label="收购方">{{ detail.purchaserOrgName }}</el-descriptions-item>
<el-descriptions-item label="经办人">{{ detail.operatorName || '-' }}</el-descriptions-item>
<el-descriptions-item label="毛重(kg)">{{ detail.grossWeight }}</el-descriptions-item>
<el-descriptions-item label="皮重(kg)">{{ detail.tareWeight || '-' }}</el-descriptions-item>
<el-descriptions-item label="净重(kg)">{{ detail.netWeight || '-' }}</el-descriptions-item>
<el-descriptions-item label="单价(元)">{{ detail.unitPrice ? detail.unitPrice.toFixed(2) : '-' }}</el-descriptions-item>
<el-descriptions-item label="金额(元)">{{ detail.amount ? detail.amount.toLocaleString() : '-' }}</el-descriptions-item>
<el-descriptions-item label="进场过磅">{{ dayjs(detail.weighInAt).format('YYYY-MM-DD HH:mm:ss') }}</el-descriptions-item>
<el-descriptions-item label="回皮时间">{{ detail.weighOutAt ? dayjs(detail.weighOutAt).format('YYYY-MM-DD HH:mm:ss') : '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 3.3 详情含补打过户记录 -->
<el-dialog v-model="detailVisible" title="收购单详情" width="760px" :close-on-click-modal="false">
<template v-if="detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="收购单号">{{ detail.orderNo }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(detail.status) }}</el-descriptions-item>
<el-descriptions-item label="农户">{{ detail.farmerName }}</el-descriptions-item>
<el-descriptions-item label="农户电话">{{ detail.farmerPhone || '-' }}</el-descriptions-item>
<el-descriptions-item label="品种">{{ detail.productName }}{{ detail.category }}</el-descriptions-item>
<el-descriptions-item label="收购方">{{ detail.purchaserOrgName }}</el-descriptions-item>
<el-descriptions-item label="经办人">{{ detail.operatorName || '-' }}</el-descriptions-item>
<el-descriptions-item label="过磅时间">{{ dayjs(detail.weighInAt).format('YYYY-MM-DD HH:mm:ss') }}</el-descriptions-item>
</el-descriptions>
<div class="panel-title" style="margin-top: 12px">磅次明细{{ detail.weighs?.length || 0 }} </div>
<el-table :data="detail.weighs || []" border size="small">
<el-table-column label="#" type="index" width="44" />
<el-table-column prop="grade" label="等级" width="80" />
<el-table-column prop="unitPrice" label="单价" width="80" align="right">
<template #default="{ row }">{{ row.unitPrice?.toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="grossWeight" label="毛重" width="80" align="right" />
<el-table-column prop="tareWeight" label="皮重" width="80" align="right" />
<el-table-column prop="boxCount" label="框数" width="70" align="center" />
<el-table-column prop="netWeight" label="净重" width="80" align="right" />
<el-table-column prop="amount" label="金额" align="right">
<template #default="{ row }">{{ fmtMoney(row.amount) }}</template>
</el-table-column>
</el-table>
<div class="grade-total" style="margin-top: 8px">
<span>合计净重<b>{{ detail.netWeight }} kg</b></span>
<span>合计框数<b>{{ detail.boxCount }} </b></span>
<span>合计金额<b>¥ {{ fmtMoney(detail.amount) }}</b></span>
</div>
<template v-if="transfers.length">
<div class="panel-title" style="margin-top: 12px">过户记录</div>
<el-table :data="transfers" border size="small">
<el-table-column prop="transferNo" label="过户单号" width="150" />
<el-table-column prop="toFarmer" label="过户至" width="100" />
<el-table-column prop="toIdCard" label="身份证" width="160" />
<el-table-column prop="operatorName" label="经办人" width="90" />
<el-table-column prop="createdAt" label="时间" width="150">
<template #default="{ row }">{{ dayjs(row.createdAt).format('YYYY-MM-DD HH:mm') }}</template>
</el-table-column>
<el-table-column prop="reason" label="原因" />
</el-table>
</template>
</template>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
<el-button v-if="detail && detail.status !== 'Cancelled'" type="primary" :loading="printing" @click="onReprintFromDetail">
<el-icon><Printer /></el-icon>&nbsp;补打单据
</el-button>
</template>
</el-dialog>
</el-card>
<!-- 票据过户 -->
<el-dialog v-model="transferVisible" title="票据过户" width="460px" destroy-on-close :close-on-click-modal="false">
<el-form label-width="90px">
<el-form-item label="原农户">
<el-input :model-value="transferForm.fromFarmer" disabled />
</el-form-item>
<el-form-item label="过户至" required>
<el-input v-model="transferForm.toFarmer" placeholder="新农户姓名" />
</el-form-item>
<el-form-item label="身份证" required>
<el-input v-model="transferForm.toIdCard" placeholder="新农户身份证号" />
</el-form-item>
<el-form-item label="过户原因">
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onTransfer">确认过户</el-button>
</template>
</el-dialog>
<!-- 3.2.6 / 3.3 小票打印 -->
<ReceiptPrint v-model="printVisible" :order="printOrder" :formats="receiptFormats" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import dayjs from 'dayjs'
import {
getPurchases, getProducts, getOrgs, getFarmerAll, createPurchase,
completeTare, updatePurchasePrice, cancelPurchase, getPurchase
getPurchases, getProducts, getOrgs, getFarmerAll,
createMultiPurchase, getPurchaseSummary, getPurchase,
completeTare, updatePurchasePrice, cancelPurchase,
reprintPurchase, transferPurchase, getPurchaseTransfers, getDictAll
} from '@/api'
import type { PurchaseOrder } from '@/types'
import type { PurchaseOrder, PurchaseWeigh, DictCodeItem, FarmerOption } from '@/types'
import ReceiptPrint from '@/components/ReceiptPrint.vue'
// ---------- 3.1 多维统计 ----------
const summary = reactive<{ orderCount: number; totalNetWeight: number; avgPrice: number; totalAmount: number; farmerCount: number; todayOrderCount: number }>({
orderCount: 0, totalNetWeight: 0, avgPrice: 0, totalAmount: 0, farmerCount: 0, todayOrderCount: 0
})
const loadSummary = async () => {
try {
Object.assign(summary, await getPurchaseSummary())
} catch { /* 忽略 */ }
}
// ---------- 列表 ----------
const rows = ref<PurchaseOrder[]>([])
const total = ref(0)
const loading = ref(false)
const products = ref<any[]>([])
const orgs = ref<any[]>([])
const farmerOptions = ref<any[]>([])
const dateRange = ref<[string, string] | null>(null)
const query = reactive({ page: 1, pageSize: 10, keyword: '', status: '', productId: undefined as number | undefined })
const statusLabel = (s: string) => ({ Weighing: '过磅中', Completed: '已完成', Cancelled: '已作废' } as Record<string, string>)[s] || s
const statusTag = (s: string) => ({ Weighing: 'warning', Completed: 'success', Cancelled: 'info' } as Record<string, string>)[s] || 'info'
const orgTypeLabel = (t: string) => ({ Company: '公司', Station: '站', Individual: '个体' } as Record<string, string>)[t] || t
const unitLabel = 'kg'
const onSizeChange = (s: number) => {
query.pageSize = s
@@ -226,36 +451,259 @@ const load = async (page?: number) => {
}
}
const searchFarmers = async (kw?: string) => {
farmerOptions.value = await getFarmerAll({ keyword: kw || '', limit: 20 })
// ---------- 数据字典 ----------
const dictMap = ref<Record<string, DictCodeItem[]>>({})
const gradeDict = computed(() => dictMap.value['purchase_grade'] || [])
const priceTypeDict = computed(() => dictMap.value['price_type'] || [])
const receiptFormats = computed(() => dictMap.value['receipt_format'] || [])
const loadDicts = async () => {
const all = await getDictAll()
const map: Record<string, DictCodeItem[]> = {}
for (const d of all) map[d.code] = d.items
dictMap.value = map
}
// ---------- 新建收购单(3.2 多磅) ----------
interface WeighRow {
grade: string
priceType: string
unitPrice: number
priceMin: number
priceMax: number
grossWeight: number
tareWeight: number
boxCount: number
netWeight: number
amount: number
}
const createVisible = ref(false)
const saving = ref(false)
const createForm = reactive<any>({})
const farmerKeyword = ref('')
const farmerOptions = ref<FarmerOption[]>([])
const selectedFarmer = ref<FarmerOption | null>(null)
const createForm = reactive<any>({ farmerId: null, productId: null, purchaserOrgId: null, notes: '' })
const weighRows = ref<WeighRow[]>([])
const newWeigh = (): WeighRow => ({
grade: gradeDict.value.find((g) => g.isDefault)?.value || gradeDict.value[0]?.value || '二等',
priceType: priceTypeDict.value.find((t) => t.isDefault)?.value || 'Fixed',
unitPrice: 0, priceMin: 0, priceMax: 0,
grossWeight: 0, tareWeight: 0, boxCount: 1, netWeight: 0, amount: 0
})
let searchTimer: ReturnType<typeof setTimeout> | null = null
const searchFarmer = async (kw?: string) => {
const key = kw ?? farmerKeyword.value
farmerOptions.value = await getFarmerAll({ keyword: key || '', limit: 20 })
}
const debounceSearchFarmer = () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => searchFarmer(), 300)
}
const selectFarmer = (f: FarmerOption) => {
selectedFarmer.value = f
createForm.farmerId = f.id
}
const onProductChange = (pid: number) => {
const p = products.value.find((x) => x.id === pid)
const defaultPrice = p?.price || 0
for (const row of weighRows.value) {
if (row.priceType === 'Fixed') row.unitPrice = defaultPrice
}
}
const addWeigh = () => weighRows.value.push(newWeigh())
const removeWeigh = (i: number) => weighRows.value.splice(i, 1)
const recalcRow = (row: WeighRow) => {
const net = Math.max((row.grossWeight || 0) - (row.tareWeight || 0), 0)
row.netWeight = Math.round(net * 100) / 100
const unitPrice = row.priceType === 'Range'
? (row.priceMin + row.priceMax) / 2
: (row.unitPrice || 0)
row.amount = Math.round(row.netWeight * unitPrice * 100) / 100
}
const applyGross = (row: WeighRow) => {
if (row.grossWeight > 0) recalcRow(row)
}
const priceColLabel = computed(() => {
const t = priceTypeDict.value.find((x) => x.value === 'Range')
return t ? `单价(${t.label})` : '单价(元)'
})
const totalNet = computed(() => Math.round(weighRows.value.reduce((s, r) => s + r.netWeight, 0) * 100) / 100)
const totalBox = computed(() => weighRows.value.reduce((s, r) => s + (r.boxCount || 0), 0))
const totalAmount = computed(() => Math.round(weighRows.value.reduce((s, r) => s + r.amount, 0) * 100) / 100)
const gradeSummaryRows = computed(() => {
const map = new Map<string, { grade: string; net: number; amount: number }>()
for (const r of weighRows.value) {
const k = r.grade || '未分级'
const cur = map.get(k) ?? { grade: k, net: 0, amount: 0 }
cur.net += r.netWeight
cur.amount += r.amount
map.set(k, cur)
}
return [...map.values()]
})
const openCreate = async () => {
Object.assign(createForm, { farmerId: null, productId: null, purchaserOrgId: null, grade: '二等', unitPrice: 0, grossWeight: 0, notes: '' })
Object.assign(createForm, { farmerId: null, productId: null, purchaserOrgId: null, notes: '' })
selectedFarmer.value = null
farmerKeyword.value = ''
farmerOptions.value = await getFarmerAll({ limit: 20 })
weighRows.value = [newWeigh()]
scaleWeight.value = 0
createVisible.value = true
}
const onCreate = async () => {
if (!createForm.farmerId || !createForm.productId || !createForm.purchaserOrgId || !createForm.grossWeight) {
ElMessage.warning('请完整填写农户、品种、收购方与毛重')
if (!createForm.farmerId) { ElMessage.warning('请先选择农户'); return }
if (!createForm.productId) { ElMessage.warning('请选择收购品种'); return }
if (!createForm.purchaserOrgId) { ElMessage.warning('请选择收购方'); return }
if (!weighRows.value.length || weighRows.value.every((r) => !r.grossWeight)) {
ElMessage.warning('请至少录入一磅毛重')
return
}
const p = products.value.find((x) => x.id === createForm.productId)
const unit = p?.unit || 'kg'
const weighs = weighRows.value.filter((r) => r.grossWeight > 0).map((r) => ({
grade: r.grade,
priceType: r.priceType,
unitPrice: r.priceType === 'Range' ? Math.round(((r.priceMin + r.priceMax) / 2) * 100) / 100 : r.unitPrice,
grossWeight: r.grossWeight,
tareWeight: r.tareWeight,
boxCount: r.boxCount,
netWeight: r.netWeight
}))
saving.value = true
try {
const order = await createPurchase(createForm)
ElMessage.success(`收购单 ${order.orderNo} 已生成,毛重 ${order.grossWeight} kg`)
const order = await createMultiPurchase({
farmerId: createForm.farmerId,
productId: createForm.productId,
purchaserOrgId: createForm.purchaserOrgId,
unit,
notes: createForm.notes,
weighs
})
ElMessage.success(`收购单 ${order.orderNo} 已生成,净重 ${order.netWeight} kg`)
createVisible.value = false
load(1)
loadSummary()
// 3.2.6 保存后进入小票打印界面
openPrint(order)
} finally {
saving.value = false
}
}
// ---------- 电子秤(3.2.2 串口) ----------
interface SerialPortLike {
open(o: { baudRate: number }): Promise<void>
readable: ReadableStream<Uint8Array> | null
close(): Promise<void>
}
const scaleWeight = ref(0)
const scaleConnected = ref(false)
const scaleConnecting = ref(false)
const manualScale = ref(false)
const manualWeight = ref(0)
let scalePort: SerialPortLike | null = null
let scaleReader: ReadableStreamDefaultReader | null = null
let scaleRun = false
const scaleLabel = computed(() => (scaleConnected.value ? '串口已连接' : manualScale.value ? '手动模式' : '未连接'))
const scaleStatus = computed(() => {
if (scaleConnected.value) return '串口已连接'
return manualScale.value ? '手动模式(输入后点击应用)' : '点击"连接电子秤"通过串口读取'
})
const toggleScale = async () => {
if (scaleConnected.value) { await disconnectScale(); return }
await connectScale()
}
const connectScale = async () => {
const nav = navigator as any
if (!nav.serial) {
ElMessage.warning('当前浏览器不支持 Web Serial(请使用 Chrome/Edge),已切换手动模式')
manualScale.value = true
return
}
scaleConnecting.value = true
try {
const port = await nav.serial.requestPort()
await port.open({ baudRate: 9600 })
scalePort = port
scaleConnected.value = true
manualScale.value = false
scaleRun = true
readScaleLoop(port)
} catch (e: any) {
if (e?.name !== 'NotFoundError') ElMessage.error('连接电子秤失败:' + (e?.message || e))
} finally {
scaleConnecting.value = false
}
}
const readScaleLoop = async (port: SerialPortLike) => {
if (!port.readable) return
const decoder = new TextDecoder()
scaleReader = port.readable.getReader()
let buf = ''
try {
while (scaleRun) {
const { value, done } = await scaleReader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split(/\r?\n/)
buf = lines.pop() || ''
for (const line of lines) {
const w = parseScaleLine(line)
if (w !== null && w >= 0) scaleWeight.value = w
}
}
} catch { /* 断开时忽略 */ }
finally {
scaleReader.releaseLock()
scaleReader = null
}
}
/** 解析常见电子秤协议:ST,GS,+0123.45kg / 123.45 kg / 123.45 */
const parseScaleLine = (line: string): number | null => {
if (!line.trim()) return null
const m = line.match(/[+\-]?(\d+(?:\.\d+)?)\s*(?:kg|KG|Kg)?/i)
return m ? parseFloat(m[1]) : null
}
const applyScaleWeight = () => {
if (!weighRows.value.length) { ElMessage.warning('请先添加一磅'); return }
const row = weighRows.value[weighRows.value.length - 1]
if (manualScale.value) {
row.grossWeight = manualWeight.value
} else {
row.grossWeight = scaleWeight.value
}
recalcRow(row)
ElMessage.success(`毛重 ${row.grossWeight.toFixed(2)} kg 已填入第 ${weighRows.value.length}`)
}
const disconnectScale = async () => {
scaleRun = false
try { scaleReader?.cancel() } catch { /* 忽略 */ }
try { await scalePort?.close() } catch { /* 忽略 */ }
scalePort = null
scaleReader = null
scaleConnected.value = false
}
// ---------- 回皮 ----------
const tareVisible = ref(false)
const tareOrder = ref<PurchaseOrder | null>(null)
const tareWeight = ref(0)
@@ -285,11 +733,13 @@ const onTare = async () => {
ElMessage.success(`回皮完成,净重 ${order.netWeight} kg,金额 ${order.amount.toLocaleString()}`)
tareVisible.value = false
load()
loadSummary()
} finally {
saving.value = false
}
}
// ---------- 调价 ----------
const priceVisible = ref(false)
const priceOrder = ref<PurchaseOrder | null>(null)
const priceForm = reactive({ unitPrice: 0, grade: '' })
@@ -306,37 +756,177 @@ const onPrice = async () => {
ElMessage.success('价格已更新')
priceVisible.value = false
load()
loadSummary()
} finally {
saving.value = false
}
}
// ---------- 作废 ----------
const onCancel = (row: PurchaseOrder) => {
ElMessageBox.confirm(`确定作废收购单「${row.orderNo}」吗?`, '提示', { type: 'warning' })
.then(async () => {
const res = await cancelPurchase(row.id)
ElMessage.success(res.message)
load()
loadSummary()
})
.catch(() => {})
}
// ---------- 3.3 详情 / 补打 ----------
const detailVisible = ref(false)
const detail = ref<PurchaseOrder | null>(null)
const transfers = ref<any[]>([])
const printing = ref(false)
const viewDetail = async (row: PurchaseOrder) => {
detail.value = await getPurchase(row.id)
transfers.value = await getPurchaseTransfers(row.id).catch(() => [])
detailVisible.value = true
}
const printVisible = ref(false)
const printOrder = ref<PurchaseOrder | null>(null)
const openPrint = (order: PurchaseOrder) => {
printOrder.value = order
printVisible.value = true
}
const onReprint = async (row: PurchaseOrder) => {
printing.value = true
try {
const res = await reprintPurchase(row.id)
ElMessage.success(res.message || '已记录补打')
const full = await getPurchase(row.id)
openPrint(full)
} finally {
printing.value = false
}
}
const onReprintFromDetail = async () => {
if (!detail.value) return
printing.value = true
try {
const res = await reprintPurchase(detail.value.id)
ElMessage.success(res.message || '已记录补打')
openPrint(detail.value)
} finally {
printing.value = false
}
}
// ---------- 3.4 更多 / 过户 ----------
const transferVisible = ref(false)
const transferOrder = ref<PurchaseOrder | null>(null)
const transferForm = reactive({ fromFarmer: '', toFarmer: '', toIdCard: '', reason: '' })
const onMore = (cmd: string, row: PurchaseOrder) => {
if (cmd === 'reprint') onReprint(row)
else if (cmd === 'transfer') openTransfer(row)
else if (cmd === 'tare') openTare(row)
else if (cmd === 'price') openPrice(row)
else if (cmd === 'cancel') onCancel(row)
}
const openTransfer = (row: PurchaseOrder) => {
transferOrder.value = row
Object.assign(transferForm, { fromFarmer: row.farmerName, toFarmer: '', toIdCard: '', reason: '' })
transferVisible.value = true
}
const onTransfer = async () => {
if (!transferForm.toFarmer || !transferForm.toIdCard) {
ElMessage.warning('请填写过户农户姓名与身份证号')
return
}
saving.value = true
try {
const res = await transferPurchase(transferOrder.value!.id, {
toFarmer: transferForm.toFarmer,
toIdCard: transferForm.toIdCard,
reason: transferForm.reason
})
ElMessage.success(`${res.message} 过户单号:${res.transferNo}`)
transferVisible.value = false
load()
} finally {
saving.value = false
}
}
// ---------- 工具 ----------
const fmtMoney = (v?: number) => (v ?? 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
onMounted(async () => {
products.value = await getProducts()
orgs.value = await getOrgs()
loadDicts()
load()
loadSummary()
})
onBeforeUnmount(() => {
scaleRun = false
scaleReader?.cancel().catch(() => {})
scalePort?.close().catch(() => {})
})
</script>
<style scoped>
.weighing-page { display: flex; flex-direction: column; gap: 12px; }
.stat-row { margin-bottom: 0; }
.stat-card {
background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
padding: 14px 16px; text-align: center; box-shadow: 0 1px 4px rgba(0,0,0,.04);
}
.stat-card .stat-num { font-size: 22px; font-weight: 700; color: #303133; line-height: 1.4; }
.stat-card:nth-child(1) .stat-num { color: #409eff; }
.stat-card:nth-child(2) .stat-num { color: #67c23a; }
.stat-card:nth-child(3) .stat-num { color: #e6a23c; }
.stat-card:nth-child(4) .stat-num { color: #f56c6c; }
.stat-card:nth-child(5) .stat-num { color: #909399; }
.stat-card:nth-child(6) .stat-num { color: #7952b3; }
.stat-card .stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
.toolbar { display: flex; justify-content: space-between; margin-bottom: 14px; flex-wrap: wrap; gap: 8px; }
.toolbar-left { display: flex; gap: 10px; flex-wrap: wrap; }
.pager { margin-top: 14px; justify-content: flex-end; }
/* 新建收购单:左右布局 */
.create-layout { display: flex; gap: 14px; }
.create-left { width: 330px; flex-shrink: 0; border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; }
.create-right { flex: 1; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.panel-title { font-weight: 600; color: #303133; margin-bottom: 8px; font-size: 14px; }
.farmer-list { max-height: 180px; overflow-y: auto; border: 1px solid #ebeef5; border-radius: 6px; margin-top: 8px; }
.farmer-item { padding: 7px 10px; cursor: pointer; border-bottom: 1px solid #f2f2f2; }
.farmer-item:hover { background: #f5f7fa; }
.farmer-item.active { background: #ecf5ff; }
.farmer-name { font-size: 13px; font-weight: 600; }
.farmer-py { color: #909399; font-weight: 400; margin-left: 4px; font-size: 11px; }
.farmer-sub { font-size: 12px; color: #909399; margin-top: 2px; }
.farmer-empty { color: #909399; font-size: 12px; text-align: center; padding: 16px 0; }
.create-form :deep(.el-form-item) { margin-bottom: 10px; }
/* 电子秤显示屏 */
.scale-panel { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; }
.scale-head { display: flex; justify-content: space-between; align-items: center; }
.scale-display {
margin-top: 10px; background: #0b1020; border-radius: 8px; color: #22ff88;
text-align: center; padding: 14px; font-family: 'DIN', 'Consolas', monospace;
}
.scale-weight { font-size: 44px; font-weight: 700; line-height: 1.1; letter-spacing: 2px; }
.scale-unit { font-size: 14px; color: #8ad9a8; margin-top: 2px; }
.scale-status { margin-top: 6px; }
.scale-manual { margin-top: 10px; }
/* 多磅操作区 */
.weigh-panel { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; flex: 1; }
.weigh-toolbar { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
.weigh-tip { color: #909399; font-size: 12px; }
.grade-summary { margin-top: 12px; border-top: 1px dashed #dcdfe6; padding-top: 10px; }
.grade-total { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 8px; font-size: 13px; color: #606266; }
.grade-total b { color: #f56c6c; }
</style>
+25 -25
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="touch">
<!-- 顶部操作栏 -->
<div class="touch-header">
@@ -272,7 +272,7 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.touch { height: 100%; display: flex; flex-direction: column; background: #f0f4f1; padding: 12px; gap: 12px; }
.touch { height: 100%; display: flex; flex-direction: column; background: #f0f2f5; padding: 12px; gap: 12px; }
.touch-header { display: flex; align-items: center; justify-content: space-between; }
.tabs { display: flex; gap: 12px; }
.tab {
@@ -281,7 +281,7 @@ onBeforeUnmount(() => {
border: none; border-radius: 12px; cursor: pointer;
background: #fff; color: #555; transition: all 0.2s;
}
.tab.active { background: #3fae60; color: #fff; box-shadow: 0 4px 14px rgba(63,174,96,0.4); }
.tab.active { background: #1a73e8; color: #fff; box-shadow: 0 4px 14px rgba(26,115,232,0.4); }
.touch-header-info { font-size: 16px; color: #666; font-weight: 600; }
.btn-ghost { border: none; background: #fff; padding: 12px 20px; font-size: 16px; border-radius: 10px; cursor: pointer; color: #555; }
.touch-body { display: flex; gap: 12px; flex: 1; min-height: 0; }
@@ -290,17 +290,17 @@ onBeforeUnmount(() => {
display: flex; flex-direction: column; gap: 12px; overflow-y: auto;
}
.side-panel { width: 320px; background: #fff; border-radius: 14px; padding: 18px; overflow-y: auto; }
.panel-title { font-size: 18px; font-weight: 700; color: #2c3e50; border-left: 4px solid #3fae60; padding-left: 10px; }
.panel-title { font-size: 18px; font-weight: 700; color: #2c3e50; border-left: 4px solid #1a73e8; padding-left: 10px; }
.search-row { margin: 4px 0; }
.grid { display: grid; gap: 10px; max-height: 210px; overflow-y: auto; padding: 4px; }
.farmer-grid { grid-template-columns: repeat(4, 1fr); }
.product-grid { grid-template-columns: repeat(5, 1fr); }
.grid-card {
border: 2px solid #e3e9e4; border-radius: 12px; padding: 12px;
cursor: pointer; text-align: center; transition: all 0.15s; background: #fafdfb;
border: 2px solid #e3e9f0; border-radius: 12px; padding: 12px;
cursor: pointer; text-align: center; transition: all 0.15s; background: #fbfdff;
}
.grid-card:hover { border-color: #3fae60; }
.grid-card.selected { border-color: #3fae60; background: #eaf7ef; box-shadow: 0 2px 10px rgba(63,174,96,0.25); }
.grid-card:hover { border-color: #1a73e8; }
.grid-card.selected { border-color: #1a73e8; background: #e8f1fe; box-shadow: 0 2px 10px rgba(26,115,232,0.25); }
.fc-name { font-size: 20px; font-weight: 700; color: #2c3e50; }
.fc-sub { font-size: 13px; color: #909399; margin-top: 3px; }
.pc-name { font-size: 18px; font-weight: 700; color: #2c3e50; }
@@ -308,52 +308,52 @@ onBeforeUnmount(() => {
.opt-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.opt-btn {
font-size: 18px; padding: 12px 24px; border-radius: 10px;
border: 2px solid #e3e9e4; background: #fff; cursor: pointer; color: #555;
border: 2px solid #e3e9f0; background: #fff; cursor: pointer; color: #555;
}
.opt-btn.active { border-color: #3fae60; background: #eaf7ef; color: #1f7a3d; font-weight: 700; }
.opt-btn.active { border-color: #1a73e8; background: #e8f1fe; color: #0d47a1; font-weight: 700; }
.price-input { display: flex; align-items: center; gap: 10px; font-size: 17px; color: #555; }
.weight-display {
border: 3px solid #3fae60; border-radius: 14px; padding: 10px 24px;
border: 3px solid #1a73e8; border-radius: 14px; padding: 10px 24px;
display: flex; align-items: baseline; justify-content: center; gap: 10px;
background: #f4fbf7; cursor: pointer;
background: #f0f6fe; cursor: pointer;
}
.weight-num { font-size: 64px; font-weight: 800; color: #1f5135; font-family: 'DIN Alternate', monospace; line-height: 1.1; }
.weight-unit { font-size: 22px; color: #6b9a7c; font-weight: 600; }
.weight-num { font-size: 64px; font-weight: 800; color: #10345f; font-family: 'DIN Alternate', monospace; line-height: 1.1; }
.weight-unit { font-size: 22px; color: #6b96c0; font-weight: 600; }
.keypad {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 4px;
}
.key {
font-size: 28px; font-weight: 700; padding: 18px 0; border: none;
border-radius: 12px; background: #f2f6f3; cursor: pointer; color: #2c3e50;
border-radius: 12px; background: #f2f5f8; cursor: pointer; color: #2c3e50;
box-shadow: 0 2px 4px rgba(0,0,0,0.06); transition: all 0.1s;
}
.key:active { transform: scale(0.96); background: #dcebe1; }
.key:active { transform: scale(0.96); background: #dce7f2; }
.key-clear { background: #fdf0e3; color: #b06d1d; }
.key-del { background: #fdeaea; color: #c0392b; }
.submit-btn {
display: flex; align-items: center; justify-content: center; gap: 10px;
font-size: 26px; font-weight: 700; padding: 20px; border: none;
border-radius: 14px; background: linear-gradient(120deg, #34a853, #3fae60);
color: #fff; cursor: pointer; box-shadow: 0 4px 16px rgba(63,174,96,0.4);
border-radius: 14px; background: linear-gradient(120deg, #1a73e8, #4a9aff);
color: #fff; cursor: pointer; box-shadow: 0 4px 16px rgba(26,115,232,0.4);
}
.submit-btn:disabled { background: #c8d4cc; box-shadow: none; cursor: not-allowed; }
.submit-btn:disabled { background: #b9c4d0; box-shadow: none; cursor: not-allowed; }
.order-list { display: flex; flex-direction: column; gap: 10px; max-height: 260px; overflow-y: auto; }
.order-card {
border: 2px solid #e3e9e4; border-radius: 12px; padding: 12px 16px; cursor: pointer; background: #fafdfb;
border: 2px solid #e3e9f0; border-radius: 12px; padding: 12px 16px; cursor: pointer; background: #fbfdff;
}
.order-card.selected { border-color: #3fae60; background: #eaf7ef; }
.order-card.selected { border-color: #1a73e8; background: #e8f1fe; }
.oc-main { display: flex; justify-content: space-between; font-size: 17px; font-weight: 600; color: #2c3e50; }
.oc-no { color: #3fae60; font-weight: 700; }
.oc-no { color: #1a73e8; font-weight: 700; }
.oc-sub { font-size: 13px; color: #909399; margin-top: 5px; }
.calc-row { display: flex; flex-wrap: wrap; gap: 8px 24px; font-size: 17px; color: #555; padding: 8px 0; }
.calc-amount { color: #e6a23c; font-weight: 800; font-size: 20px; }
.empty-tip { text-align: center; color: #aaa; padding: 40px 0; font-size: 16px; }
.side-list { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.side-item {
border: 2px solid #e3e9e4; border-radius: 12px; padding: 12px 14px; cursor: pointer; background: #fafdfb;
border: 2px solid #e3e9f0; border-radius: 12px; padding: 12px 14px; cursor: pointer; background: #fbfdff;
}
.side-item:hover { border-color: #3fae60; }
.si-no { font-size: 14px; color: #3fae60; font-weight: 700; }
.side-item:hover { border-color: #1a73e8; }
.si-no { font-size: 14px; color: #1a73e8; font-weight: 700; }
.si-name { font-size: 16px; font-weight: 600; color: #2c3e50; margin-top: 4px; }
.si-w { font-size: 14px; color: #e6a23c; margin-top: 4px; }
.si-t { font-size: 12px; color: #909399; margin-top: 2px; }