Platform upgrade: login auth fix, OCR/OSS upload, system menus and dicts, weather dashboard, weighing and invoice modules
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}` +
|
||||
`¤t=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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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> 可视化大屏
|
||||
</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,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)) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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> 触摸屏过磅
|
||||
</el-button>
|
||||
<el-button type="success" size="large" @click="openDashboard">
|
||||
<el-icon><Monitor /></el-icon> 可视化大屏
|
||||
</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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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> 新增农户
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@
|
||||
<el-button type="primary" plain @click="openCameraDialog">
|
||||
<el-icon><Camera /></el-icon> 读取设备(高拍仪)
|
||||
</el-button>
|
||||
<el-button type="success" plain @click="openMobileDialog">
|
||||
<el-button type="primary" plain @click="openMobileDialog">
|
||||
<el-icon><Iphone /></el-icon> 手机传图
|
||||
</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 分钟)或网络异常:停止轮询并提示,可重新生成
|
||||
|
||||
@@ -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> 批量开票
|
||||
</el-button>
|
||||
<el-button type="success" @click="openCreate()">
|
||||
<el-button type="primary" @click="openCreate()">
|
||||
<el-icon><Plus /></el-icon> 开具销售发票
|
||||
</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>
|
||||
@@ -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> 新增组织
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -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> 发起支付
|
||||
</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; }
|
||||
|
||||
@@ -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> 新增品种
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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> 新增用户
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -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> 触摸屏过磅
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 新建收购单
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="warning" @click="$router.push('/weighing/touch')">
|
||||
<el-icon><Iphone /></el-icon> 触摸屏过磅
|
||||
</el-button>
|
||||
<el-button type="success" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 新建收购单
|
||||
</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> 添加一磅
|
||||
</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> 保存并打印小票
|
||||
</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> 补打单据
|
||||
</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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user