change store
Some checks failed
CI / build (20.x) (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / deploy-preview (push) Blocked by required conditions
CI / security (push) Waiting to run
CI / build (18.x) (push) Has been cancelled

This commit is contained in:
97694731 2026-06-04 11:18:46 +08:00
parent 8d080fce47
commit 0543936df8
35 changed files with 8681 additions and 144 deletions

117
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,117 @@
name: CI
on:
push:
branches: [ main, master, develop ]
pull_request:
branches: [ main, master, develop ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.node-version }}
path: dist/
retention-days: 7
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
continue-on-error: true
- name: TypeScript type check
run: npx tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
continue-on-error: true
deploy-preview:
needs: [build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist-20.x
path: dist/
- name: Deploy to preview environment
run: echo "Preview deployment would happen here"
# 可以添加实际的部署步骤,例如部署到 Vercel、Netlify 等
security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run npm audit
run: npm audit --audit-level=moderate
continue-on-error: true
- name: Run Snyk security scan
uses: snyk/actions/node@master
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

384
backups/EmployeeAdd.vue.bak Normal file
View File

@ -0,0 +1,384 @@
<template>
<div class="employee-add">
<el-card>
<template>
<div class="card-header">
<span>添加代理</span>
<el-button @click="$router.push('/admin/employees')">
<el-icon>
<Back />
</el-icon>
</el-button>
</div>
</template>
<el-steps :active="activeStep" finish-status="success" simple class="steps">
<el-step title="填写信息" />
<el-step title="确认信息" />
<el-step title="完成" />
</el-steps>
<!-- 第一步填写信息 -->
<div v-if="activeStep === 1" class="step-content">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="add-form">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入代理姓名" :prefix-icon="User" clearable />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="请输入密码至少6位" :prefix-icon="Lock"
show-password clearable />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" placeholder="请再次输入密码" :prefix-icon="Lock"
show-password clearable />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" :prefix-icon="Lock" maxlength="11" clearable
show-word-limit />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="nextStep" :loading="checking">
下一步确认信息
</el-button>
</el-form-item>
</el-form>
<!-- 工号预览动态生成 -->
<el-card shadow="never" class="preview-card">
<template #header>
<span>工号预览</span>
</template>
<div class="preview-content">
<div class="preview-item">
<span class="label">工号格式</span>
<span class="value">5位数字自动生成</span>
</div>
<div class="preview-item">
<span class="label">账号格式</span>
<span class="value">dl_ + 工号 dl_00001</span>
</div>
<div class="preview-item">
<span class="label">示例工号</span>
<el-tag size="small">{{ previewEmployeeId }}</el-tag>
</div>
<div class="preview-item">
<span class="label">示例账号</span>
<el-tag size="small" type="success">{{ previewUsername }}</el-tag>
</div>
</div>
</el-card>
</div>
<!-- 第二步确认信息 -->
<div v-if="activeStep === 2" class="step-content">
<el-descriptions :column="1" border class="confirm-info">
<el-descriptions-item label="姓名">{{ form.name }}</el-descriptions-item>
<el-descriptions-item label="工号">{{ previewEmployeeId }}</el-descriptions-item>
<el-descriptions-item label="登录账号">{{ previewUsername }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ form.phone }}</el-descriptions-item>
<el-descriptions-item label="初始积分">0</el-descriptions-item>
<el-descriptions-item label="角色">代理</el-descriptions-item>
<el-descriptions-item label="状态">正常</el-descriptions-item>
</el-descriptions>
<div class="step-actions">
<el-button @click="prevStep">上一步</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
确认添加
</el-button>
</div>
</div>
<!-- 第三步完成 -->
<div v-if="activeStep === 3" class="step-content result">
<el-result icon="success" :title="`添加成功`" :sub-title="`代理 ${resultData?.name} (${resultData?.username}) 已添加`">
<template #extra>
<div class="result-info" ref="resultInfoRef">
<el-alert type="info" :closable="false" show-icon>
<p class="title-center">进销存系统</p>
<p>登录网址https://psi.buzhiyushu.cn/</p>
<p>工号{{ resultData?.employee_id }}</p>
<p>账号{{ resultData?.username }}</p>
<p>初始密码{{ form.password }}</p>
<p>手机号{{ form.phone }}</p>
<p style="color: #f56c6c; margin-top: 10px;">请妥善保管账号信息</p>
</el-alert>
</div>
<div class="result-actions">
<el-button type="primary" @click="resetForm">继续添加</el-button>
<el-button @click="$router.push('/admin/employees')">查看列表</el-button>
<el-button type="success" @click="copyResultInfo">
<el-icon>
<DocumentCopy />
</el-icon>
一键复制
</el-button>
</div>
</template>
</el-result>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, Back, DocumentCopy } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { copyToClipboard } from '@/utils/clipboard'
import { useUserStore } from '@/store/user'
const router = useRouter()
const userStore = useUserStore()
const activeStep = ref(1)
const checking = ref(false)
const submitting = ref(false)
const formRef = ref(null)
const resultData = ref(null)
const resultInfoRef = ref(null)
//
const form = reactive({
name: '',
password: '',
confirmPassword: '',
phone: ''
})
//
const validatePass = (rule, value, callback) => {
if (value === '') {
callback(new Error('请输入密码'))
} else if (value.length < 6) {
callback(new Error('密码长度不能小于6位'))
} else {
if (form.confirmPassword !== '') {
formRef.value?.validateField('confirmPassword')
}
callback()
}
}
const validatePass2 = (rule, value, callback) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== form.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const rules = {
name: [
{ required: true, message: '请输入代理姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度应为2-20个字符', trigger: 'blur' }
],
password: [
{ validator: validatePass, trigger: 'blur' }
],
confirmPassword: [
{ validator: validatePass2, trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
//
const previewEmployeeId = ref('00001')
const previewUsername = computed(() => `dl_${previewEmployeeId.value}`)
//
const fetchNextEmployeeId = async () => {
try {
//
// 使
const res = await request.get('/admin/employee/list', { params: { page: 1, page_size: 1 } })
if (res.code === 200 && res.data.list.length > 0) {
const lastId = res.data.list[0].employee_id
const seq = parseInt(lastId) + 1
previewEmployeeId.value = String(seq).padStart(5, '0')
}
} catch (error) {
// console.error(':', error)
// 使
previewEmployeeId.value = String(Math.floor(Math.random() * 90000 + 10000)).padStart(5, '0')
}
}
//
const nextStep = async () => {
if (!formRef.value) return
await formRef.value.validate()
activeStep.value = 2
}
//
const prevStep = () => {
activeStep.value = 1
}
//
const submitForm = async () => {
submitting.value = true
try {
// fid login idabout_id login about_id
const currentInfo = userStore.getAdminInfo()
const params = {
name: form.name,
password: form.password,
phone: form.phone,
}
if (currentInfo?.id) {
params.fid = currentInfo.id
}
if (currentInfo?.about_id) {
params.about_id = currentInfo.about_id
}
const res = await request.post('/admin/employee/add', params)
if (res.code === 200) {
resultData.value = res.data
activeStep.value = 3
ElMessage.success('添加成功')
}
} catch (error) {
// console.error(':', error)
ElMessage.error('添加失败,请重试')
} finally {
submitting.value = false
}
}
//
const resetForm = () => {
form.name = ''
form.password = ''
form.confirmPassword = ''
form.phone = ''
activeStep.value = 1
resultData.value = null
fetchNextEmployeeId()
}
//
const copyResultInfo = async () => {
if (!resultData.value) return
const text = `书海寻源代理系统
登录网址https://wallet.buzhiyushu.cn/
工号${resultData.value.employee_id}
账号${resultData.value.username}
初始密码${form.password}
手机号${form.phone}
请妥善保管账号信息`
await copyToClipboard(text, '账号信息已复制到剪贴板')
}
//
onMounted(() => {
fetchNextEmployeeId()
})
</script>
<style scoped>
.employee-add {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.steps {
margin: 20px 0 40px;
}
.step-content {
min-height: 300px;
padding: 20px 0;
}
.add-form {
width: 500px;
margin: 0 auto;
}
.preview-card {
width: 500px;
margin: 30px auto 0;
background-color: #f8f9fa;
}
.preview-content {
padding: 10px;
}
.preview-item {
margin-bottom: 10px;
display: flex;
align-items: center;
}
.preview-item .label {
width: 80px;
color: #666;
font-size: 13px;
}
.preview-item .value {
color: #333;
font-size: 13px;
}
.confirm-info {
width: 500px;
margin: 0 auto;
}
.step-actions {
margin-top: 30px;
text-align: center;
}
.result {
display: flex;
justify-content: center;
}
.result-info {
margin: 20px 0;
text-align: left;
}
.result-actions {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: center;
}
:deep(.el-descriptions__label) {
width: 120px;
}
.title-center {
text-align: center;
font-weight: bold;
font-size: 16px;
margin-bottom: 10px;
}
</style>

View File

@ -0,0 +1,494 @@
<template>
<div class="employee-list">
<el-card>
<template #header>
<div class="card-header">
<span>代理列表</span>
<div class="header-actions">
<el-button type="primary" @click="$router.push('/admin/employees/add')">
<el-icon>
<Plus />
</el-icon>
</el-button>
</div>
</div>
</template>
<!-- 搜索条件 -->
<div class="search-form">
<el-form :inline="true" :model="queryParams">
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部状态" clearable style="width: 150px">
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="搜索">
<el-input v-model="queryParams.keyword" placeholder="工号/姓名/账号" clearable style="width: 200px"
:prefix-icon="Search" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 统计卡片 -->
<el-row :gutter="20" class="stat-cards">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-label">总代理数</div>
<div class="stat-value">{{ total }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-label">正常代理</div>
<div class="stat-value success">{{ activeCount }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-label">总积分</div>
<div class="stat-value warning">{{ totalPoints }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-label">平均积分</div>
<div class="stat-value info">{{ averagePoints }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 代理列表 -->
<el-table :data="employeeList" v-loading="loading" border style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="employee_id" label="工号" width="100" align="center" />
<el-table-column prop="username" label="账号" width="150" align="center" />
<el-table-column prop="name" label="姓名" width="120" align="center" />
<el-table-column prop="phone" label="手机号" width="120" align="center" />
<el-table-column prop="score" label="积分" width="100" align="center">
<template #default="{ row }">
<span :class="{ 'points-warning': row.score < 100 }">{{ row.score }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="code" label="机械码" width="240" align="center" />
<el-table-column prop="level_info" label="等级" width="60" align="center" />
<el-table-column prop="last_login_at" label="最后登录" width="160" align="center">
<template #default="{ row }">
{{ row.last_login_at ? formatDate(row.last_login_at) : '-' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160" align="center" >
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<!-- <el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="showTopUp(row)">
<el-icon><Coin /></el-icon>
</el-button>
<el-button type="success" size="small" @click="viewAccess(row)">
<el-icon><DataLine /></el-icon>
</el-button>
<el-button
v-if="row.status === 1"
type="warning"
size="small"
@click="toggleStatus(row, 'disable')"
>
<el-icon><Switch /></el-icon>
</el-button>
<el-button
v-else
type="success"
size="small"
@click="toggleStatus(row, 'enable')"
>
<el-icon><Check /></el-icon>
</el-button>
<el-button type="danger" size="small" @click="showDeduct(row)">
<el-icon><Coin /></el-icon>
</el-button>
</template>
</el-table-column> -->
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size" :total="total"
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</el-card>
<!-- 充值弹窗 -->
<el-dialog v-model="topUpVisible" title="积分充值" width="400px">
<el-form :model="topUpForm" ref="topUpFormRef" :rules="topUpRules" label-width="80px">
<el-form-item label="代理">
<span>{{ currentEmployee?.name }} ({{ currentEmployee?.employee_id }})</span>
</el-form-item>
<el-form-item label="当前积分">
<span>{{ currentEmployee?.points }}</span>
</el-form-item>
<el-form-item label="充值数量" prop="amount">
<el-input-number v-model="topUpForm.amount" :min="1" :max="100000" style="width: 200px" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="topUpForm.remark" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="topUpVisible = false">取消</el-button>
<el-button type="primary" @click="submitTopUp" :loading="topUpLoading">确认充值</el-button>
</span>
</template>
</el-dialog>
<!-- 扣减弹窗 -->
<el-dialog v-model="deductVisible" title="积分扣减" width="400px">
<el-form :model="deductForm" ref="deductFormRef" :rules="deductRules" label-width="80px">
<el-form-item label="代理">
<span>{{ currentEmployee?.name }} ({{ currentEmployee?.employee_id }})</span>
</el-form-item>
<el-form-item label="当前积分">
<span>{{ currentEmployee?.points }}</span>
</el-form-item>
<el-form-item label="扣减数量" prop="amount" class="deduct-label">
<el-input-number class="deduct-input" v-model="deductForm.amount" :min="1" :max="currentEmployee?.points || 1"
style="width: 200px" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="deductForm.remark" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="deductVisible = false">取消</el-button>
<el-button type="danger" @click="submitDeduct" :loading="deductLoading">确认扣减</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Coin, DataLine, Switch, Check } from '@element-plus/icons-vue'
import request from '@/utils/request'
const router = useRouter() // router
const loading = ref(false)
const employeeList = ref([])
const total = ref(0)
const topUpVisible = ref(false)
const topUpLoading = ref(false)
const deductVisible = ref(false)
const deductLoading = ref(false)
const currentEmployee = ref(null)
const queryParams = reactive({
page: 1,
page_size: 20,
status: '',
keyword: ''
})
const topUpForm = reactive({
amount: 100,
remark: ''
})
const deductForm = reactive({
amount: 100,
remark: ''
})
const topUpRules = {
amount: [
{ required: true, message: '请输入充值数量', trigger: 'blur' },
{ type: 'number', min: 1, message: '充值数量必须大于0', trigger: 'blur' }
]
}
const deductRules = {
amount: [
{ required: true, message: '请输入扣减数量', trigger: 'blur' },
{ type: 'number', min: 1, message: '扣减数量必须大于0', trigger: 'blur' }
]
}
//
const activeCount = computed(() => {
return employeeList.value.filter(e => e.status === 1).length
})
const totalPoints = computed(() => {
return employeeList.value.reduce((sum, e) => sum + e.score, 0)
})
const averagePoints = computed(() => {
if (employeeList.value.length === 0) return 0
return Math.round(totalPoints.value / employeeList.value.length)
})
//
const formatDate = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
//
const fetchEmployeeList = async () => {
loading.value = true
try {
const params = {
page: queryParams.page,
page_size: queryParams.page_size
}
if (queryParams.status !== '') {
params.status = queryParams.status
}
if (queryParams.keyword) {
params.keyword = queryParams.keyword
}
const res = await request.get('/admin/employee/list', { params })
if (res.code === 200) {
employeeList.value = res.data.list
total.value = res.data.total
}
} catch (error) {
// console.error(':', error)
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
queryParams.page = 1
fetchEmployeeList()
}
//
const resetSearch = () => {
queryParams.status = ''
queryParams.keyword = ''
queryParams.page = 1
fetchEmployeeList()
}
//
const handleSizeChange = (size) => {
queryParams.page_size = size
fetchEmployeeList()
}
//
const handleCurrentChange = (page) => {
queryParams.page = page
fetchEmployeeList()
}
//
const showTopUp = (row) => {
currentEmployee.value = row
topUpForm.amount = 100
topUpForm.remark = ''
topUpVisible.value = true
}
//
const submitTopUp = async () => {
topUpLoading.value = true
try {
const res = await request.post(`/admin/employee/topup/${currentEmployee.value.employee_id}`, {
amount: topUpForm.amount,
remark: topUpForm.remark
})
if (res.code === 200) {
ElMessage.success('充值成功')
topUpVisible.value = false
fetchEmployeeList() //
}
} catch (error) {
// console.error(':', error)
} finally {
topUpLoading.value = false
}
}
//
const showDeduct = (row) => {
currentEmployee.value = row
deductForm.amount = 100
deductForm.remark = ''
deductVisible.value = true
}
//
const submitDeduct = async () => {
deductLoading.value = true
try {
const res = await request.post(`/admin/employee/deduct/${currentEmployee.value.employee_id}`, {
amount: deductForm.amount,
remark: deductForm.remark
})
if (res.code === 200) {
ElMessage.success('扣减成功')
deductVisible.value = false
fetchEmployeeList() //
}
} catch (error) {
// console.error(':', error)
} finally {
deductLoading.value = false
}
}
//
const toggleStatus = async (row, action) => {
const actionText = action === 'enable' ? '启用' : '禁用'
try {
await ElMessageBox.confirm(`确定要${actionText}代理 ${row.name} 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await request.post(`/admin/employee/${action}/${row.employee_id}`)
if (res.code === 200) {
ElMessage.success(`${actionText}成功`)
fetchEmployeeList() //
}
} catch (error) {
if (error !== 'cancel') {
// console.error(':', error)
}
}
}
//
const viewAccess = (row) => {
// ID
router.push({
path: '/admin/access',
query: {
id: row.id,
}
})
}
//
onMounted(() => {
fetchEmployeeList()
})
</script>
<style scoped>
.employee-list {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
/* 可根据需要保留原有的字体、颜色等样式 */
font-size: 16px;
font-weight: 600;
color: #303133;
}
.search-form {
margin-bottom: 20px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 4px;
}
.stat-cards {
margin-bottom: 20px;
}
.stat-card {
text-align: center;
}
.stat-item {
padding: 10px;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #303133;
}
.stat-value.success {
color: #67c23a;
}
.stat-value.warning {
color: #e6a23c;
}
.stat-value.info {
color: #409eff;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.points-warning {
color: #f56c6c;
font-weight: bold;
}
.deduct-input :deep(.el-input-number__decrease):hover,
.deduct-input :deep(.el-input-number__increase):hover {
color: #fff;
background-color: #f56c6c;
}
.deduct-label :deep(.el-form-item__label) {
color: #f56c6c;
font-weight: bold;
}
</style>

213
backups/Login.vue.bak Normal file
View File

@ -0,0 +1,213 @@
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h2>进销存系统</h2>
<p>请选择登录角色</p>
</div>
<el-tabs v-model="activeTab" class="login-tabs">
<el-tab-pane label="管理员登录" name="admin">
<el-form ref="adminFormRef" :model="adminForm" :rules="rules" label-width="0" class="login-form">
<el-form-item prop="username">
<el-input v-model="adminForm.username" placeholder="请输入账号" :prefix-icon="User"
size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="adminForm.password" type="password" placeholder="请输入密码"
:prefix-icon="Lock" size="large" show-password @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" class="login-btn" size="large"
@click="handleLogin">
登录
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="代理登录" name="employee">
<el-form ref="employeeFormRef" :model="employeeForm" :rules="rules" label-width="0"
class="login-form">
<el-form-item prop="username">
<el-input v-model="employeeForm.username" placeholder="请输入账号" :prefix-icon="User"
size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="employeeForm.password" type="password" placeholder="请输入密码"
:prefix-icon="Lock" size="large" show-password @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" class="login-btn" size="large"
@click="handleLogin">
登录
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<!-- <div class="login-footer">
<p>默认账号代理 init_00001 / 123456</p>
<p>管理员 init_00000 / admin123</p>
</div> -->
</div>
</div>
</template>
<script setup>
import {
ref,
reactive
} from 'vue'
import {
useRouter
} from 'vue-router'
import {
ElMessage
} from 'element-plus'
import {
User,
Lock
} from '@element-plus/icons-vue'
import request from '@/utils/request'
import {
setAdminToken,
setAdminUserInfo
} from '@/utils/auth'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
const router = useRouter()
const activeTab = ref('admin')
const loading = ref(false)
// ref
const employeeFormRef = ref(null)
const adminFormRef = ref(null)
const employeeForm = reactive({
username: '',
password: '',
about_id:0
// username: 'init_00001',
// password: '123456'
})
const adminForm = reactive({
username: '',
password: '',
about_id:0
// username: 'init_00000',
// password: 'admin123'
})
const rules = {
username: [{
required: true,
message: '请输入账号',
trigger: 'blur'
}],
password: [{
required: true,
message: '请输入密码',
trigger: 'blur'
},
{
min: 6,
message: '密码长度不能小于6位',
trigger: 'blur'
}
]
}
const handleLogin = async () => {
const formRef = activeTab.value === 'employee' ? employeeFormRef : adminFormRef
const formData = activeTab.value === 'employee' ? employeeForm : adminForm
await formRef.value.validate()
loading.value = true
try {
const type = activeTab.value === 'admin' ? '255' : '128'
const res = await request.post(`/login/${type}`, { ...formData, type: 1 })
if (res.code === 200) {
setAdminToken(res.data.token)
setAdminUserInfo(res.data)
userStore.setUserInfoAction(res.data)
//
localStorage.setItem('test_ip', '127.0.0.1')
localStorage.setItem('test_port', '8080')
ElMessage.success('登录成功')
//
router.push('/dashboard')
}
} catch (error) {
// console.error(':', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
font-size: 24px;
color: #333;
margin-bottom: 10px;
}
.login-header p {
color: #666;
font-size: 14px;
}
.login-tabs {
margin-bottom: 20px;
}
.login-form {
margin-top: 20px;
}
.login-btn {
width: 100%;
margin-top: 10px;
}
.login-footer {
margin-top: 30px;
text-align: center;
color: #999;
font-size: 12px;
}
.login-footer p {
margin: 5px 0;
}
</style>

View File

@ -0,0 +1,475 @@
<template>
<div class="review-page">
<el-card class="section-card">
<template #header>
<div class="card-header">
<span>异常书目审核</span>
</div>
</template>
<!-- 搜索筛选栏 -->
<div class="search-bar">
<div class="search-item">
<span>ISBN</span>
<el-input v-model="searchForm.isbn" placeholder="请输入ISBN" style="width: 180px" clearable />
</div>
<div class="search-item">
<span>状态</span>
<el-select v-model="searchForm.status" placeholder="请选择状态" style="width: 140px" clearable>
<el-option label="待审核" value="0" />
<el-option label="已通过" value="1" />
<el-option label="已驳回" value="2" />
</el-select>
</div>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%"
@selection-change="handleSelectionChange">
<el-table-column prop="status" label="状态" width="150" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="barcode" label="ISBN" width="160" align="center" />
<el-table-column label="书名" min-width="200" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<span :class="{ 'diff-cell': isChanged(row.old_name, row.new_name) }">{{ row.old_name }}</span>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ row.old_name }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ row.new_name }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="作者" width="150" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<span :class="{ 'diff-cell': isChanged(row.old_author, row.new_author) }">{{ row.old_author }}</span>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ row.old_author }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ row.new_author }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="出版社" width="150" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<span :class="{ 'diff-cell': isChanged(row.old_publisher, row.new_publisher) }">{{ row.old_publisher }}</span>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ row.old_publisher }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ row.new_publisher }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="套装书" width="150" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<el-tag type="info" size="small" :class="{ 'diff-cell': isChanged(row.old_is_suit, row.new_is_suit) }">
{{ row.old_is_suit === 0 ? '非套装书' : '套装书' }}
</el-tag>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ row.old_is_suit === 0 ? '非套装书' : '套装书' }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ row.new_is_suit === 0 ? '非套装书' : '套装书' }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="定价" width="150" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<span :class="{ 'diff-cell': isChanged(row.old_price, row.new_price) }">{{ row.old_price }}</span>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ row.old_price }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ row.new_price }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="装帧" width="150" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<span :class="{ 'diff-cell': isChanged(row.old_binding_layout, row.new_binding_layout) }">{{ row.old_binding_layout }}</span>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ row.old_binding_layout }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ row.new_binding_layout }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="页数" width="150" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<span :class="{ 'diff-cell': isChanged(row.old_page_count, row.new_page_count) }">{{ row.old_page_count }}</span>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ row.old_page_count }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ row.new_page_count }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="字数" width="150" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<span :class="{ 'diff-cell': isChanged(row.old_word_count, row.new_word_count) }">{{ row.old_word_count }}</span>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ row.old_word_count }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ row.new_word_count }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="出版时间" width="150" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-popover placement="bottom" :width="280" trigger="hover" popper-class="diff-popover">
<template #reference>
<span :class="{ 'diff-cell': isPubTimeChanged(row.old_publication_time, row.new_publication_time) }">{{ formatPubTime(row.old_publication_time) }}</span>
</template>
<div class="diff-content">
<div class="diff-row old"><span class="diff-label">旧值</span><span class="diff-val">{{ formatPubTime(row.old_publication_time) }}</span></div>
<div class="diff-row new"><span class="diff-label">新值</span><span class="diff-val">{{ formatPubTime(row.new_publication_time) }}</span></div>
</div>
</el-popover>
</template>
</el-table-column>
<el-table-column label="提交时间" width="180" align="center">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" @click="handleApprove(row)">
查看
</el-button>
<el-button type="danger" @click="handleReject(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination v-model:current-page="pagination.current" v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageChange" @current-change="handlePageChange" />
</div>
</el-card>
<!-- 查看详情弹窗 -->
<el-dialog v-model="detailVisible" title="调剂记录详情" width="60%" :close-on-click-modal="false">
<template v-if="detailData">
<el-descriptions :column="2" border>
<el-descriptions-item label="ISBN">{{ detailData.isbn }}</el-descriptions-item>
<el-descriptions-item label="书名">{{ detailData.bookName }}</el-descriptions-item>
<el-descriptions-item label="作者">{{ detailData.author }}</el-descriptions-item>
<el-descriptions-item label="出版社">{{ detailData.publisher }}</el-descriptions-item>
<el-descriptions-item label="异常类型">{{ detailData.abnormalType }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detailData.status)" size="small">
{{ statusText(detailData.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="异常说明" :span="2">
{{ detailData.abnormalDesc || '无' }}
</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ detailData.createdAt }}</el-descriptions-item>
<el-descriptions-item v-if="detailData.reviewTime" label="审核时间">{{ detailData.reviewTime
}}</el-descriptions-item>
</el-descriptions>
</template>
</el-dialog>
<!-- 审核弹窗 - 使用 SubmIllegalBook 组件 -->
<SubmIllegalBook
v-model:visible="reviewDialogVisible"
:review-data="reviewRecord"
:goods="null"
@review="handleReviewResult"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import SubmIllegalBook from '@/components/dialog/submIllegalBook/SubmIllegalBook.vue'
//
const searchForm = reactive({
isbn: '',
status: ''
})
//
const tableData = ref<any[]>([])
const loading = ref(false)
const selectedRows = ref<any[]>([])
//
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0
})
//
const detailVisible = ref(false)
const detailData = ref<any>(null)
//
const reviewDialogVisible = ref(false)
const reviewRecord = ref<any>(null)
//
function statusTagType(status: string): string {
const map: Record<string, string> = {
0: 'warning',
1: 'success',
2: 'danger'
}
return map[status] || 'info'
}
// yyyy-mm10
function formatPubTime(time: any): string {
if (time === null || time === undefined || time === '') return ''
const ts = Number(time)
if (isNaN(ts) || ts <= 0) return ''
const d = new Date(ts * 1000)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
// yyyy-mm-dd HH:mm:ss10
function formatDateTime(time: any): string {
if (time === null || time === undefined || time === '') return ''
const ts = Number(time)
if (isNaN(ts) || ts <= 0) return ''
const d = new Date(ts * 1000)
const y = d.getFullYear()
const M = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
const s = String(d.getSeconds()).padStart(2, '0')
return `${y}-${M}-${day} ${h}:${m}:${s}`
}
//
function isChanged(oldVal: any, newVal: any): boolean {
//
if ((oldVal === null || oldVal === undefined || oldVal === '') &&
(newVal === null || newVal === undefined || newVal === '')) {
return false
}
return String(oldVal) !== String(newVal)
}
//
function isPubTimeChanged(oldTs: any, newTs: any): boolean {
return isChanged(formatPubTime(oldTs), formatPubTime(newTs))
}
//
function statusText(status: string): string {
const map: Record<string, string> = {
0: '待审核',
1: '已通过',
2: '已驳回'
}
return map[status] || status
}
//
function handleSearch() {
pagination.current = 1
fetchData()
}
//
function handleReset() {
searchForm.isbn = ''
searchForm.status = ''
handleSearch()
}
//
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
}
//
function handlePageChange() {
fetchData()
}
//
async function fetchData() {
loading.value = true
try {
const res = await request.get('/product_log/list', {
params: {
barcode: searchForm.isbn || undefined,
status: searchForm.status || undefined,
page: pagination.current,
page_size: pagination.pageSize
}
})
const data = res?.data
if (data) {
tableData.value = Array.isArray(data.list) ? data.list : Array.isArray(data) ? data : []
pagination.total = data.total || 0
} else {
tableData.value = []
pagination.total = 0
}
} catch (err) {
ElMessage.error('获取列表失败')
} finally {
loading.value = false
}
}
// -
function handleApprove(row: any) {
reviewRecord.value = row
reviewDialogVisible.value = true
}
//
async function handleReject(row: any) {
try {
await ElMessageBox.confirm('确认删除该条记录?', '提示', {
type: 'warning'
})
const formData = new FormData()
formData.append('id', String(row.id))
await request.post('/product_log/delete', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
ElMessage.success('已删除')
fetchData()
} catch {
//
}
}
// -
function handleReviewResult(action: 'approve' | 'reject', record: any) {
fetchData()
}
//
function handleViewDetail(row: any) {
detailData.value = row
detailVisible.value = true
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.review-page {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-card {
margin-bottom: 16px;
}
.search-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
}
.search-item span {
font-size: 14px;
color: #606266;
white-space: nowrap;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
<style>
/* 新旧数据对比 - 单元格变红 */
.diff-cell {
color: #f56c6c !important;
font-weight: bold;
}
/* Popover 内容样式(全局,因 popover 渲染在 body 下) */
.diff-popover {
padding: 8px 12px !important;
}
.diff-content {
font-size: 13px;
line-height: 1.6;
}
.diff-row {
display: flex;
align-items: flex-start;
gap: 4px;
padding: 2px 0;
}
.diff-label {
white-space: nowrap;
color: #909399;
flex-shrink: 0;
}
.diff-val {
color: #303133;
word-break: break-all;
}
.diff-row.new .diff-val {
color: #e6a23c;
font-weight: bold;
}
</style>

View File

@ -0,0 +1,771 @@
<template>
<el-card class="shipping-order-manager">
<template #header>
<div class="card-header">发货单管理</div>
</template>
<div class="filter-bar">
<el-input v-model="searchParams.keyword" placeholder="发货单号" clearable style="width: 200px"
@keyup.enter="handleSearch">
<template #prefix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
<el-select v-model="searchParams.status" placeholder="状态" clearable style="width: 120px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="searchParams.warehouse_id" placeholder="仓库" clearable style="width: 140px">
<el-option v-for="item in warehouseOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="searchParams.customer_id" placeholder="客户" clearable filterable style="width: 160px">
<el-option v-for="item in customerOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="resetSearch">重置</el-button>
</div>
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%"
@expand-change="handleExpandChange">
<!-- 发货单详情展开行 -->
<el-table-column type="expand">
<template #default="{ row }">
<div v-if="detailCache[row.id]" style="padding: 12px 20px">
<h4 style="margin: 0 0 10px; font-size: 14px; color: #303133">发货明细</h4>
<el-table :data="detailCache[row.id].items || []" border size="small">
<el-table-column prop="product_name" label="商品名称" min-width="120" show-overflow-tooltip align="center" />
<el-table-column prop="product_code" label="ISBN/条码" min-width="100" show-overflow-tooltip
align="center" />
<el-table-column label="库位" min-width="100" align="center">
<template #default="{ row: item }">
{{ locationMap[item.warehouse_code] || item.warehouse_code || '-' }}##{{
locationMap[item.location_name]
|| item.location_name || '-' }}
</template>
</el-table-column>
<el-table-column label="销售单号" min-width="140" align="center">
<template #default="{ row: item }">
<a v-if="item.sales_order_no" style="color: #409eff; cursor: pointer; text-decoration: underline;"
@click.stop="navigateToSalesOrder(item.sales_order_no)">
{{ item.sales_order_no }}
</a>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="出库单号" min-width="140" align="center">
<template #default="{ row: item }">
<a v-if="item.outbound_order_no" style="color: #409eff; cursor: pointer; text-decoration: underline;"
@click.stop="navigateToOutbound(item.outbound_order_no)">
{{ item.outbound_order_no }}
</a>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="association_order_no" label="平台单号" min-width="90" align="center">
<template #default="{ row: item }">
<span style="display: inline-flex; align-items: center; gap: 4px;">
<span v-if="item.association_order_no" style="color: #409eff; text-decoration: underline;">{{ item.association_order_no }}</span>
<span v-else>-</span>
<el-button v-if="item.association_order_no" type="primary" size="small" link
@click="copyToClipboard(item.association_order_no, '平台单号已复制')">
<el-icon>
<CopyDocument />
</el-icon>
</el-button>
</span>
</template>
</el-table-column>
<el-table-column label="销售时间" min-width="100" align="center">
<template #default="{ row: item }">{{ formatDateForSale(item.sales_order_created_at) }}</template>
</el-table-column>
<el-table-column label="售价" min-width="40" align="center">
<template #default="{ row: item }">
<span style="color: #e6a23c; font-weight: 600">{{ formatAmount(item.unit_price) }}</span>
</template>
</el-table-column>
<el-table-column prop="quantity" label="出库数量" min-width="60" align="center">
<template #default="{ row: item }">
<span style="color: #409eff; font-weight: 600">{{ item.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="快递公司" min-width="100" align="center">
<template #default="{ row: item }">{{ logisticsCompanyMap[item.logistics_company] ||
item.logistics_company || '-' }}</template>
</el-table-column>
<el-table-column prop="logistics_no" label="快递单号" min-width="100" align="center">
<template #default="{ row: item }">{{ item.logistics_no || '-' }}</template>
</el-table-column>
<el-table-column prop="receiver_address" label="收货地址" min-width="100" align="center">
<template #default="{ row: item }">{{ item.receiver_address || '-' }}</template>
</el-table-column>
</el-table>
</div>
<div v-else style="padding: 20px; text-align: center; color: #909399">
<el-icon class="is-loading" style="margin-right: 6px">
<Loading />
</el-icon>...
</div>
</template>
</el-table-column>
<el-table-column prop="shipping_no" label="发货单号" min-width="160" show-overflow-tooltip align="center" />
<el-table-column label="客户" min-width="160" align="center">
<template #default="{ row }">
<template v-if="row.shop_list && row.shop_list.length > 0">
<div v-for="(shop, idx) in row.shop_list" :key="idx">
{{ shop.shop_name }}({{ shop.shop_type_text }})
</div>
</template>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="status_text" label="状态" min-width="80" show-overflow-tooltip align="center" />
<el-table-column prop="operator" label="操作员" min-width="90" align="center">
<template #default="{ row }">{{ row.operator || '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="150" align="center">
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip align="center">
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column label="操作" align="center" width="100">
<template #default="{ row }">
<el-button type="primary" size="small" link @click="handleStartShipping(row)">开始发货</el-button>
</template>
</el-table-column>
<el-table-column label="" align="center" width="70">
<template #default="{ row }">
<el-button type="primary" size="small" link>打单</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="pagination.current" v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
<!-- 扫码发货弹窗 -->
<el-dialog v-model="scanDialogVisible" title="扫码发货" width="1000px" :close-on-click-modal="false"
@opened="onScanDialogOpened" @closed="onScanDialogClosed">
<div style="padding: 10px">
<p style="margin-bottom: 12px; color: #606266; text-align: center">
<strong style="color:#409eff">{{ currentScanTotal }}</strong> 件商品已扫描 <strong style="color:#67c23a">{{
currentScanIndex }}</strong>
</p>
<el-table :data="scanList" border size="small" style="width: 100%" max-height="340"
:row-class-name="scanRowClassName">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="product_name" label="商品名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="product_code" label="ISBN" width="120" align="center" />
<el-table-column prop="quantity" label="数量" width="60" align="center" />
<el-table-column prop="association_order_no" label="平台单号" min-width="150" align="center"
show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.association_order_no" style="display: inline-flex; align-items: center; gap: 4px;">
<span style="color: #409eff; text-decoration: underline;">{{ row.association_order_no }}</span>
<el-button type="primary" size="small" link
@click="copyToClipboard(row.association_order_no, '平台单号已复制')">
<el-icon>
<CopyDocument />
</el-icon>
</el-button>
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="快递单号" min-width="120" align="center">
<template #default="{ row, $index }">
<span v-if="$index < currentScanIndex && row.logistics_no" style="color: #67c23a;">{{ row.logistics_no
}}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="售价" width="80" align="center">
<template #default="{ row }">
<span style="color: #e6a23c; font-weight: 600">{{ formatAmount(row.unit_price) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row, $index }">
<template v-if="$index < currentScanIndex || row.logistics_no">
<el-tag type="success" size="small">已扫</el-tag>
</template>
<template v-else-if="$index === processingIndex">
<el-tag type="warning" size="small" effect="dark">扫描中</el-tag>
</template>
<template v-else>
<el-tag type="info" size="small">待扫</el-tag>
</template>
</template>
</el-table-column>
</el-table>
<el-input ref="scanInputRef" v-model="scanCode" placeholder="请用扫码枪扫描 ISBN..." @keyup.enter="handleScanSubmit"
class="hidden-scan-input" />
<p style="margin-top: 8px; color: #c0c4cc; font-size: 12px; text-align: center">请使用扫码枪扫描商品条码</p>
</div>
</el-dialog>
</el-card>
</template>
<script lang="ts">
import { defineComponent, ref, reactive, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, Refresh, View, Loading, CopyDocument } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import axios from 'axios'
import { copyToClipboard } from '@/utils/clipboard'
import { fetchShippingOrderList, fetchShippingOrderDetail, updateShippingOrderLogistics } from '@/api/shipping-order'
import { fetchWarehouseList } from '@/api/warehouse'
import { createPrintTask } from '@/api/print'
/** 状态映射 */
const STATUS_MAP: Record<number, { label: string; type: string }> = {
1: { label: '已创建', type: 'info' },
2: { label: '拣货中', type: 'warning' },
3: { label: '已完成', type: 'success' },
4: { label: '已取消', type: 'danger' }
}
export default defineComponent({
name: 'ShippingOrder',
setup() {
const router = useRouter()
const loading = ref<boolean>(false)
const tableData = ref<any[]>([])
/** 导航到销售订单页面 */
const navigateToSalesOrder = (salesOrderNo: string) => {
router.push({ name: 'sales-order', query: { keyword: salesOrderNo } })
}
/** 导航到出库单页面 */
const navigateToOutbound = (outboundOrderNo: string) => {
router.push({ name: 'outbound', query: { keyword: outboundOrderNo } })
}
const statusOptions = Object.entries(STATUS_MAP).map(([value, { label }]) => ({
value: Number(value),
label
}))
const searchParams = reactive<{
keyword: string
status: number | null
warehouse_id: number | null
customer_id: number | null
}>({
keyword: '',
status: null,
warehouse_id: null,
customer_id: null
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const detailVisible = ref<boolean>(false)
const detailData = ref<any>(null)
//
const warehouseOptions = ref<any[]>([])
const customerOptions = ref<any[]>([])
// ID
const warehouseMap = ref<Record<string, string>>({})
const customerMap = ref<Record<string, string>>({})
const salesOrderMap = ref<Record<string, string>>({})
const waveTaskMap = ref<Record<string, string>>({})
const locationMap = ref<Record<string, string>>({})
//
const detailCache = ref<Record<number, any>>({})
// ========== ==========
const scanDialogVisible = ref(false)
const scanCode = ref('')
const scanInputRef = ref<any>(null)
const scanList = ref<any[]>([]) //
const currentScanIndex = ref(0) //
const currentScanTotal = ref(0) //
const scanFocusTimer = ref<ReturnType<typeof setInterval> | null>(null)
const currentScanItem = computed(() => scanList.value[currentScanIndex.value] || null)
const formatAmount = (amount: number): string => {
if (!amount && amount !== 0) return '¥0.00'
return '¥' + (Number(amount) / 100).toFixed(2)
}
//
const isProcessing = ref(false)
const processingIndex = ref(-1)
/** 已扫描完成的行添加背景色 */
const scanRowClassName = ({ row, rowIndex }: { row: any; rowIndex: number }) => {
if (rowIndex < currentScanIndex.value || row.logistics_no) {
return 'scan-completed-row'
}
return ''
}
/** 点击 "开始发货" */
const handleStartShipping = async (row: any) => {
//
let detail = detailCache.value[row.id]
if (!detail || !detail.items) {
try {
detail = await fetchShippingOrderDetail(row.id)
detailCache.value[row.id] = detail
} catch (e) {
ElMessage.error('加载发货明细失败')
return
}
}
const items = detail?.items || []
if (items.length === 0) {
ElMessage.warning('该发货单没有明细')
return
}
scanList.value = items.map((item: any) => ({ ...item, shipping_order_id: row.id }))
//
const firstUnscanned = scanList.value.findIndex((item: any) => !item.logistics_no)
currentScanIndex.value = firstUnscanned >= 0 ? firstUnscanned : scanList.value.length
currentScanTotal.value = items.length
scanCode.value = ''
scanDialogVisible.value = true
}
/** 弹窗打开后聚焦并锁定焦点到隐藏 input */
const onScanDialogOpened = () => {
const focusInput = () => {
scanInputRef.value?.focus?.()
scanInputRef.value?.$el?.querySelector?.('input')?.focus?.()
}
//
nextTick(() => { focusInput() })
// 200ms
const intervalId = setInterval(() => {
if (!scanDialogVisible.value) {
clearInterval(intervalId)
return
}
//
const active = document.activeElement
const inputEl = scanInputRef.value?.$el?.querySelector?.('input')
if (inputEl && active !== inputEl) {
inputEl.focus()
}
}, 200)
// dialog interval
const origClose = onScanDialogClosed
const cleanup = () => {
clearInterval(intervalId)
origClose()
}
}
/** 弹窗关闭时清理 */
const onScanDialogClosed = () => {
scanCode.value = ''
scanList.value = []
currentScanIndex.value = 0
currentScanTotal.value = 0
}
/** 扫码回车处理 */
const handleScanSubmit = async () => {
const code = scanCode.value.trim()
if (!code) return
//
if (isProcessing.value) return
//
isProcessing.value = true
processingIndex.value = currentScanIndex.value
// ISBN
const matchIndex = scanList.value.findIndex(
(item: any) => item.product_code === code
)
if (matchIndex === -1) {
ElMessage.warning(`未找到 ISBN 为 ${code} 的商品`)
isProcessing.value = false
processingIndex.value = -1
scanCode.value = ''
return
}
//
if (matchIndex !== currentScanIndex.value) {
const temp = scanList.value[currentScanIndex.value]
scanList.value[currentScanIndex.value] = scanList.value[matchIndex]
scanList.value[matchIndex] = temp
}
const currentItem = scanList.value[currentScanIndex.value]
if (!currentItem) {
isProcessing.value = false
processingIndex.value = -1
return
}
const salesPersonId = currentItem.sales_person_id
if (!salesPersonId) {
ElMessage.warning('当前商品缺少 sales_person_id')
isProcessing.value = false
processingIndex.value = -1
scanCode.value = ''
return
}
try {
//
const res = await axios.get('https://api.buzhiyushu.cn/zhishu/fastMail/listApi', {
params: { shopId: salesPersonId }
})
console.log('fastMail/listApi 响应:', res.data)
// fastMailType=1
const data = res.data?.data
if (!Array.isArray(data) || data.length === 0) {
ElMessage.error('未获取到快递账号配置')
isProcessing.value = false
processingIndex.value = -1
scanCode.value = ''
return
}
const fastMail = data.find((item: any) => item.fastMailType === '1')
if (!fastMail || !currentItem.association_order_no) {
ElMessage.error('未找到默认快递配置或缺少平台单号')
isProcessing.value = false
processingIndex.value = -1
scanCode.value = ''
return
}
const formData = new FormData()
formData.append('type', fastMail.type)
formData.append('partnerId', fastMail.partnerId)
formData.append('secret', fastMail.secret)
formData.append('orderSn', currentItem.association_order_no)
formData.append('contact', currentItem.warehouse_contact_person)
formData.append('phoneNumber', currentItem.warehouse_contact_phone)
formData.append('province', currentItem.warehouse_province)
formData.append('city', currentItem.warehouse_city)
formData.append('area', currentItem.warehouse_district)
formData.append('town', currentItem.warehouse_address)
const createRes = await axios.post('https://print.buzhiyushu.cn/api/print/createOrderBatch', formData)
console.log('createOrderBatch 响应:', createRes.data)
// PDF
const createData = await axios.get('https://print.buzhiyushu.cn/api/print/createBmOrderDaYin', {
params: {
mailno: createRes.data.data[0].mail_no || createRes.data.data[0].mailno,
partnerId: fastMail.partnerId,
secret: fastMail.secret
}
})
console.log('createBmOrderDaYin 响应:', createData.data)
const printData = createRes.data.data[0]
printData.pdf_info = createData.data.pdfInfo
printData.itemList = createRes.data.erpGoodsOrderList[0].itemList;
console.log('createBmOrderDaYin 响应:', JSON.stringify(printData))
// createPrintTask
console.log('正在创建打印任务并打印...')
const LODOP = await createPrintTask('yunda', printData) as any
LODOP.SET_PRINTER_INDEX(localStorage.getItem('printer_express'));
const printResult = LODOP.PRINT()
console.log('打印结果:', printResult)
// updateShippingOrderLogistics
if (currentItem.sales_order_item_id) {
await updateShippingOrderLogistics({
shipping_order_id: currentItem.shipping_order_id,
sales_order_item_id: currentItem.sales_order_item_id,
logistics_company: fastMail.type,
logistics_no: createRes.data.data[0].mail_no || createRes.data.data[0].mailno
})
}
console.log('正在回填快递单号并更新状态...')
const submitCompany = await axios.post('https://api.buzhiyushu.cn/zhishu/orderExternalGoods/submitCompanyOrder', {
code: fastMail.type,
orderNo: createRes.data.data[0].mail_no || createRes.data.data[0].mailno,
erpOrderId: createRes.data.erpGoodsOrderList[0].id.toString()
}, {
headers: { 'Content-Type': 'application/json' }
});
console.log('回填快递单号 响应:', submitCompany.data)
//
scanList.value[currentScanIndex.value].logistics_no = createRes.data.data[0].mail_no || createRes.data.data[0].mailno
} catch (err) {
console.error('发货处理失败:', err)
ElMessage.error('发货处理失败,请重试')
isProcessing.value = false
processingIndex.value = -1
scanCode.value = ''
return
}
//
isProcessing.value = false
processingIndex.value = -1
currentScanIndex.value++
scanCode.value = ''
if (currentScanIndex.value >= scanList.value.length) {
ElMessage.success('全部扫描完成!')
scanDialogVisible.value = false
}
}
const statusLabel = (status: number): string => STATUS_MAP[status]?.label || '未知'
const statusTagType = (status: number): string => STATUS_MAP[status]?.type || 'info'
const formatTimestamp = (timestamp?: number | string | null): string => {
if (!timestamp && timestamp !== 0) return '-'
return dayjs.unix(Number(timestamp)).format('YYYY-MM-DD HH:mm')
}
const formatDate = (date?: number | string | null): string => {
if (!date) return '-'
if (typeof date === 'number' && date < 10000000000) {
return dayjs.unix(date).format('YYYY-MM-DD')
}
return dayjs(date).format('YYYY-MM-DD')
}
const formatDateForSale = (date?: number | string | null): string => {
if (!date) return '-'
if (typeof date === 'number' && date < 10000000000) {
return dayjs.unix(date).format('YYYY-MM-DD HH:mm')
}
return dayjs(date).format('YYYY-MM-DD HH:mm')
}
/** 快递公司映射 */
const logisticsCompanyMap: Record<string, string> = {
YUNDA: '韵达快递'
}
/** 加载下拉选项和名称映射 */
const loadOptions = async (): Promise<void> => {
try {
//
const warehouseRes = await fetchWarehouseList({ keyword: '', page: 1, pageSize: 9999 })
warehouseOptions.value = warehouseRes.list || []
const wMap: Record<string, string> = {}
for (const w of warehouseRes.list) {
wMap[String(w.id)] = w.name || w.code || String(w.id)
}
warehouseMap.value = wMap
// TODO: API
customerOptions.value = []
customerMap.value = {}
} catch (error) {
// console.error(':', error)
}
}
/** 加载发货单列表 */
const loadList = async (): Promise<void> => {
loading.value = true
try {
const res = await fetchShippingOrderList({
check_no: searchParams.keyword,
status: searchParams.status || undefined,
warehouse_id: searchParams.warehouse_id || undefined,
customer_id: searchParams.customer_id || undefined,
page: pagination.current,
pageSize: pagination.pageSize
})
tableData.value = res.list || []
pagination.total = res.total || 0
} catch (error) {
// console.error(':', error)
ElMessage.error('加载发货单列表失败')
} finally {
loading.value = false
}
}
/** 搜索 */
const handleSearch = (): void => {
pagination.current = 1
loadList()
}
/** 重置搜索 */
const resetSearch = (): void => {
searchParams.keyword = ''
searchParams.status = null
searchParams.warehouse_id = null
searchParams.customer_id = null
pagination.current = 1
loadList()
}
/** 分页大小变化 */
const handleSizeChange = (size: number): void => {
pagination.pageSize = size
loadList()
}
/** 页码变化 */
const handleCurrentChange = (page: number): void => {
pagination.current = page
loadList()
}
/** 展开行时加载详情 */
const handleExpandChange = async (row: any, expandedRows: any[]): Promise<void> => {
if (expandedRows.find((r: any) => r.id === row.id) && !detailCache.value[row.id]) {
try {
const detail = await fetchShippingOrderDetail(row.id)
detailCache.value[row.id] = detail
} catch (error) {
// console.error(':', error)
detailCache.value[row.id] = { items: [] }
}
}
}
/** 查看发货单详情 */
const handleView = async (row: any): Promise<void> => {
try {
const detail = await fetchShippingOrderDetail(row.id)
detailData.value = detail
detailVisible.value = true
} catch (error) {
// console.error(':', error)
ElMessage.error('加载详情失败')
}
}
onMounted(() => {
loadOptions()
loadList()
})
return {
loading,
tableData,
statusOptions,
searchParams,
pagination,
detailVisible,
detailData,
warehouseOptions,
customerOptions,
warehouseMap,
customerMap,
salesOrderMap,
waveTaskMap,
locationMap,
detailCache,
scanDialogVisible,
scanInputRef,
scanCode,
scanList,
currentScanIndex,
currentScanTotal,
currentScanItem,
isProcessing,
processingIndex,
formatAmount,
scanRowClassName,
handleStartShipping,
onScanDialogOpened,
onScanDialogClosed,
handleScanSubmit,
statusLabel,
statusTagType,
formatTimestamp,
formatDate,
formatDateForSale,
logisticsCompanyMap,
handleSearch,
resetSearch,
handleSizeChange,
handleCurrentChange,
handleExpandChange,
handleView,
navigateToSalesOrder,
navigateToOutbound,
copyToClipboard,
Search,
Refresh,
View,
Loading,
CopyDocument
}
}
})
</script>
<style scoped lang="scss">
.shipping-order-manager {
width: 100%;
}
.card-header {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
:deep(.el-table__expanded-cell) {
padding: 0;
}
/* 隐藏扫码输入框,仅保留焦点功能 */
.hidden-scan-input {
position: absolute;
left: -9999px;
opacity: 0;
width: 1px;
height: 1px;
}
/* 已完成扫描的行背景色 */
:deep(.el-table__row.scan-completed-row) {
background-color: #ecf5ff;
}
:deep(.el-table__row.scan-completed-row:hover > td) {
background-color: #ecf5ff;
}
</style>

3364
backups/camera.vue.bak Normal file

File diff suppressed because it is too large Load Diff

97
backups/config.js.bak Normal file
View File

@ -0,0 +1,97 @@
import axios from 'axios'
/**
* 测试连接核价器
* @param {string} ip - IP 地址
* @param {string} port - 端口
* @returns {Promise}
*/
export const testConnection = (ip, port) => {
const params = new URLSearchParams()
params.append('isbn', '0')
params.append('out_id', '0')
params.append('quality', '0')
params.append('query_index', '1')
params.append('user_id', '0')
return axios.post(`http://${ip}:${port}/api/goods/query`, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 10000
}).then(res => res.data)
}
/**
* 保存新价格到核价器
* @param {string} ip - IP 地址
* @param {string} port - 端口
* @returns {Promise}
*/
export const saveNewPrice = (ip, port, newPrice, placeholderDownPrice, minShippingFee, minPrice, verifyIndex) => {
const formData = new URLSearchParams()
formData.append('new_price', newPrice)
formData.append('placeholder_down_price', placeholderDownPrice)
formData.append('min_shipping_fee', minShippingFee)
formData.append('min_price', minPrice)
formData.append('query_index', verifyIndex)
console.log(formData.toString())
return axios.post(`http://${ip}:${port}/api/config/price/set`, formData.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 10000
}).then(res => res.data)
}
/**
* 孔夫子旧书网登录直接请求核价器
* @param {string} username - 孔网用户名
* @param {string} password - 孔网密码
* @param {string} ip - 核价器 IP
* @param {string} port - 核价器端口
* @returns {Promise<{code: number, data: {token: string, nickname: string}, message: string}>}
*/
export const kongfzLogin = (username, password, ip, port) => {
const formData = new FormData()
formData.append('username', username)
formData.append('password', password)
return axios.post(`http://${ip}:${port}/api/kfz/login`, formData, {
timeout: 15000
}).then(res => res.data)
}
/**
* 批量提交 Token 到核价器
* @param {Array<{username: string, token: string}>} tokens - 账号 Token 列表
* @param {string} ip - 核价器 IP
* @param {string} port - 核价器端口
* @returns {Promise}
*/
export const batchAddTokens = (tokens, ip, port) => {
return axios.post(`http://${ip}:${port}/api/token/add`, tokens, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
}).then(res => res.data)
}
/**
* 从核价器获取已保存的 Token 列表
* @param {string} ip - 核价器 IP
* @param {string} port - 核价器端口
* @returns {Promise<{code: number, data: Array<{Username: string, Token: string, ID: number, IsEnable: boolean}>, message: string}>}
*/
export const fetchTokenList = (ip, port) => {
return axios.post(`http://${ip}:${port}/api/token/list`, {}, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
}).then(res => res.data)
}
/**
* 从核价器获取config配置
* @param {string} ip - 核价器 IP
* @param {string} port - 核价器端口
*/
export const fetchConfig = (ip, port) => {
return axios.post(`http://${ip}:${port}/api/config/price/get`, {}, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
}).then(res => res.data)
}

968
backups/config.vue.bak Normal file
View File

@ -0,0 +1,968 @@
<template>
<div class="config-page">
<el-card class="config-card" shadow="always">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon :size="20">
<Setting />
</el-icon>
<span>核价器配置</span>
</div>
</div>
</template>
<el-form label-width="80px" label-position="top">
<div class="config-row">
<el-form-item class="flex-item">
<template #label>
<span class="label-with-icon">
<span>程序所在位置</span>
<el-tooltip content="verifyTool的位置" placement="top" trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-input v-model="dir" placeholder="如 C:\\verifyTool" clearable @clear="dir = ''" />
<el-tooltip content="通过自定义协议启动本机程序" placement="top" trigger="click">
<el-button
type="primary"
:icon="VideoPlay"
style="margin-left: 10px"
:disabled="!dir"
@click="handleOpenExe"
>
打开程序
</el-button>
</el-tooltip>
</el-form-item>
</div>
<div class="config-row">
<!-- IP地址 -->
<el-form-item class="flex-item">
<template #label>
<span class="label-with-icon">
<span>IP 地址</span>
<el-tooltip content="一般都是 127.0.0.1 如需特殊配置 请联系网管" placement="top" trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-input v-model="ip" placeholder="如 192.168.1.1" clearable />
</el-form-item>
<!-- 端口 -->
<el-form-item class="flex-item">
<template #label>
<span class="label-with-icon">
<span>端口</span>
<el-tooltip content="默认是8080 但是由于每台电脑的环境都不同 可能会出现端口冲突 在出现问题时候 请第一时间联系网管" placement="top" trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-input v-model="port" placeholder="如 8080" clearable />
</el-form-item>
<!-- 核价位置 -->
<el-form-item class="flex-item">
<template #label>
<span class="label-with-icon">
<span>核价位置</span>
<el-tooltip content="默认 1 根据实际情况自行填写" placement="top" trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-input v-model="verifyIndex" placeholder="请输入核价位置" clearable />
</el-form-item>
<!-- 核价失败默认价格 -->
<el-form-item class="flex-item">
<template #label>
<span class="label-with-icon">
<span>核价失败统一默认价格</span>
<el-tooltip content="例88888 便于再店铺中快速搜索到这些商品 商品修改价格" placement="top" trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-input v-model="newPrice" placeholder="请输入核价失败时的默认价格" clearable @input="e => onPriceInput(e, 'newPrice')"
@blur="onPriceBlur('newPrice')" />
</el-form-item>
</div>
<div class="config-row">
<!-- 占位降价 -->
<el-form-item class="flex-item">
<template #label>
<span class="label-with-icon">
<span>占位降价</span>
<el-tooltip content="占位降价金额" placement="top" trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-input v-model="placeholderDownPrice" placeholder="请输入占位降价" clearable
@input="e => onPriceInput(e, 'placeholderDownPrice')"
@blur="enforceMinPlaceholderDownPrice(); onPriceBlur('placeholderDownPrice')" />
</el-form-item>
<!-- 最低运费 -->
<el-form-item class="flex-item">
<template #label>
<span class="label-with-icon">
<span>最低运费</span>
<el-tooltip content="订单最低运费金额" placement="top" trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-input v-model="minShippingFee" placeholder="请输入最低运费" clearable
@input="e => onPriceInput(e, 'minShippingFee')" @blur="onPriceBlur('minShippingFee')" />
</el-form-item>
<!-- 最低书价 -->
<el-form-item class="flex-item">
<template #label>
<span class="label-with-icon">
<span>最低书价</span>
<el-tooltip content="单本书籍最低售价" placement="top" trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-input v-model="minPrice" placeholder="请输入最低书价" clearable @input="e => onPriceInput(e, 'minPrice')"
@blur="onPriceBlur('minPrice')" />
</el-form-item>
</div>
<div class="save-bar">
<el-tooltip content="测试核价器服务连接状态" placement="top" trigger="click">
<el-button type="primary" :loading="testLoading" @click="handleTest" size="small">测试连接</el-button>
</el-tooltip>
<el-tooltip content="保存当前核价器配置" placement="top" trigger="click">
<el-button type="success" @click="handleSave">保存配置</el-button>
</el-tooltip>
</div>
<transition name="fade">
<div v-if="result" class="result-box">
<el-alert :title="result.success ? '✅ 连接成功' : '❌ 连接失败'" :type="result.success ? 'success' : 'error'"
:description="result.message" show-icon :closable="false" />
</div>
</transition>
</el-form>
<el-divider />
<!-- 账号管理 -->
<div class="account-section">
<div class="section-title">
<el-icon :size="18">
<User />
</el-icon>
<span>绑定孔夫子旧书网账号</span>
<el-tooltip content="这里是为了波次提交之后店铺同步商品时候有一个相对准确市场价格(如果没有价格我们将走设置的统一默认价格)" placement="top" trigger="click">
<el-icon style="cursor: pointer; margin-left: 8px;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
<!-- 有数据 表格 + 追加账号按钮 -->
<template v-if="savedAccountList.length > 0">
<el-table :data="savedAccountList" border stripe size="small" style="width: 100%">
<el-table-column prop="username" label="用户名" min-width="140" align="center" />
<el-table-column prop="token" label="Token" min-width="260" show-overflow-tooltip align="center">
<template #default="{ row }">
<span class="token-text">{{ row.token }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-tooltip content="删除该账号绑定" placement="top" trigger="click">
<el-button type="danger" link :icon="Delete" @click="deleteSavedAccount(row)">删除</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<!-- 追加账号按钮 -->
<div class="append-bar">
<el-button v-if="!showAppendForm" type="primary" plain :icon="Plus" @click="showAppendForm = true">
追加账号
</el-button>
</div>
<!-- 追加账号输入表单 -->
<transition name="fade">
<div v-if="showAppendForm" class="account-form">
<div v-for="(item, index) in appendAccounts" :key="index" class="account-row">
<div class="account-label">
追加账号{{ ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || (index + 1) }}
</div>
<div class="account-inputs">
<!-- 账号输入框 -->
<el-form-item :label-width="'0'" style="margin-bottom: 0;">
<template #label>
<span class="label-with-icon" style="display: none;"></span>
</template>
<el-input v-model="item.account" placeholder="请输入账号" clearable autocomplete="off" />
</el-form-item>
<!-- 密码输入框 -->
<el-form-item :label-width="'0'" style="margin-bottom: 0;">
<el-input v-model="item.password" type="password" placeholder="请输入密码" clearable show-password
autocomplete="new-password" />
</el-form-item>
<el-tooltip v-if="appendAccounts.length > 1" content="删除此条账号输入" placement="top" trigger="click">
<el-button type="danger" :icon="Delete" circle
@click="removeAppendAccount(index)" />
</el-tooltip>
</div>
</div>
<div class="append-form-actions">
<el-tooltip content="再增加一组账号密码输入" placement="top" trigger="click">
<el-button plain :icon="Plus" @click="addAppendAccount">添加更多</el-button>
</el-tooltip>
<el-tooltip content="绑定当前填写的所有追加账号" placement="top" trigger="click">
<el-button type="primary" :loading="appendLoading" :icon="Link"
@click="handleAppendBind">绑定追加账号</el-button>
</el-tooltip>
<el-button @click="cancelAppend">取消</el-button>
</div>
</div>
</transition>
</template>
<!-- 无数据 输入表单 -->
<template v-else>
<div class="account-form">
<div v-for="(item, index) in accounts" :key="index" class="account-row">
<div class="account-label">账号{{ ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] }}</div>
<div class="account-inputs">
<el-input v-model="item.account" placeholder="请输入账号" clearable autocomplete="off" />
<el-input v-model="item.password" type="password" placeholder="请输入密码" clearable show-password
autocomplete="new-password" />
<el-tooltip v-if="accounts.length > 1" content="删除此条账号输入" placement="top" trigger="click">
<el-button type="danger" :icon="Delete" circle
@click="removeAccount(index)" />
</el-tooltip>
</div>
</div>
<el-tooltip content="再增加一组账号密码输入" placement="top" trigger="click">
<el-button type="primary" plain :icon="Plus" @click="addAccount" class="add-btn">添加更多账号</el-button>
</el-tooltip>
</div>
</template>
<!-- 绑定账号按钮无已绑定账号时显示 -->
<div v-if="savedAccountList.length === 0" class="bind-bar">
<el-tooltip content="绑定新的核价器账号" placement="top" trigger="click">
<el-button type="primary" :loading="bindLoading" :icon="Link" @click="handleBind">绑定账号</el-button>
</el-tooltip>
</div>
</div>
</el-card>
</div>
</template>
<script lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus, Setting, User, Link, VideoPlay } from '@element-plus/icons-vue'
import {
testConnection,
kongfzLogin,
batchAddTokens,
fetchTokenList,
saveNewPrice,
fetchConfig,
} from '@/api/config'
const STORAGE_KEY_FILE_DIR = 'file_dir'
const STORAGE_KEY_IP = 'test_ip'
const STORAGE_KEY_PORT = 'test_port'
const STORAGE_KEY_VERIFY_INDEX = 'verify_index'
const STORAGE_KEY_SAVED_ACCOUNTS = 'saved_accounts'
interface AccountEntry {
id?: number
account: string
token: string
username: string
}
interface FormAccount {
account: string
password: string
token?: string
username?: string
}
/** 从 localStorage 读取已保存的账号列表 */
function loadSavedAccounts(): AccountEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY_SAVED_ACCOUNTS)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
/** 持久化已保存的账号列表到 localStorage */
function saveSavedAccounts(list: AccountEntry[]): void {
localStorage.setItem(STORAGE_KEY_SAVED_ACCOUNTS, JSON.stringify(list))
}
export default {
name: 'Config',
setup() {
//
const dir = ref(localStorage.getItem(STORAGE_KEY_FILE_DIR) || '')
const ip = ref(localStorage.getItem(STORAGE_KEY_IP) || '127.0.0.1')
const port = ref(localStorage.getItem(STORAGE_KEY_PORT) || '8080')
const verifyIndex = ref(localStorage.getItem(STORAGE_KEY_VERIFY_INDEX) || '3')
const fmt = (v: string | null, d: string) => { const n = parseFloat(v ?? ''); return isNaN(n) ? d : n.toFixed(2) }
const newPrice = ref(fmt(localStorage.getItem('new_price'), '9999.00'))
const placeholderDownPrice = ref(fmt(localStorage.getItem('placeholder_down_price'), '0.01'))
const minShippingFee = ref(fmt(localStorage.getItem('min_shipping_fee'), '3.00'))
const minPrice = ref(fmt(localStorage.getItem('min_price'), '1.00'))
//
const accounts = ref<FormAccount[]>([{ account: '', password: '', token: '', username: '' }])
// Token
const savedAccountList = ref<AccountEntry[]>([])
//
const showAppendForm = ref(false)
const appendAccounts = ref<FormAccount[]>([{ account: '', password: '', token: '', username: '' }])
const appendLoading = ref(false)
/** 添加新的账号输入行 */
const addAccount = () => {
accounts.value.push({ account: '', password: '', token: '', username: '' })
}
/** 删除账号输入行 */
const removeAccount = (index: number) => {
accounts.value.splice(index, 1)
}
/** 添加新的追加账号输入行 */
const addAppendAccount = () => {
appendAccounts.value.push({ account: '', password: '', token: '', username: '' })
}
/** 删除追加账号输入行 */
const removeAppendAccount = (index: number) => {
appendAccounts.value.splice(index, 1)
}
/** 取消追加账号操作,重置表单 */
const cancelAppend = () => {
showAppendForm.value = false
appendAccounts.value = [{ account: '', password: '', token: '', username: '' }]
}
/** 占位降价失焦时强制最低值为 0.01 */
const enforceMinPlaceholderDownPrice = () => {
const val = parseFloat(placeholderDownPrice.value)
if (isNaN(val) || val < 0.01) {
placeholderDownPrice.value = '0.01'
ElMessage.warning('占位降价不能低于 0.01,已自动设为 0.01')
}
}
/** 金额输入过滤:只允许数字+小数点最多2位小数保持光标位置 */
const onPriceInput = (value: string, key: string) => {
const raw = value
const filtered = raw
.replace(/[^\d.]/g, '') //
.replace(/^\./, '0.') // 0
.replace(/\.{2,}/g, '.') //
.replace(/^(\d+\.?\d{0,2}).*$/, '$1') // 2
const map: Record<string, any> = { newPrice, placeholderDownPrice, minShippingFee, minPrice }
map[key].value = filtered
}
/** 失焦时自动补全 .00:纯整数 → 末尾补 .00 */
const onPriceBlur = (key: string) => {
const map: Record<string, any> = { newPrice, placeholderDownPrice, minShippingFee, minPrice }
const val = map[key].value as string
// .00
if (val && /^\d+$/.test(val)) {
map[key].value = val + '.00'
} else if (val && /^\d+\.$/.test(val)) {
// "12." "12.00"
map[key].value = val + '00'
} else if (val && /^\d+\.\d$/.test(val)) {
// "12.3" "12.30"
map[key].value = val + '0'
}
}
const testLoading = ref(false)
const bindLoading = ref(false)
const result = ref<{ success: boolean; message: string } | null>(null)
/** 通过自定义协议启动本地 kfz-goods-pricing.exe */
const handleOpenExe = () => {
if (!dir.value) {
ElMessage.warning('请先设置程序所在位置')
return
}
try {
// kfzgs://launcher.exe dir exe
window.location.href = `kfzgs://launch?dir=${encodeURIComponent(dir.value)}`
} catch (err: any) {
ElMessage.error(`启动失败: ${err.message}`)
}
}
/** 将单个账号写入已保存列表 */
const addToSavedList = (entry: AccountEntry) => {
const list = loadSavedAccounts()
const idx = list.findIndex(a => a.account === entry.account)
if (idx >= 0) {
list[idx] = entry
} else {
list.push(entry)
}
saveSavedAccounts(list)
savedAccountList.value = list
}
/** 删除已保存的账号 */
const deleteSavedAccount = async (entry: AccountEntry) => {
try {
await ElMessageBox.confirm(`确定删除账号「${entry.account}」的 Token 记录吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
//
await axios.get(`http://${ip.value}:${port.value}/api/token/delete`, {
params: { id: entry.id }
})
// username localStorage key
localStorage.removeItem(entry.username)
const list = loadSavedAccounts().filter(a => a.account !== entry.account)
saveSavedAccounts(list)
savedAccountList.value = list
ElMessage.success('已删除')
} catch {
//
}
}
/** 加载已保存的账号列表 */
const loadSavedAccountsList = () => {
savedAccountList.value = loadSavedAccounts()
}
/** 测试核价器连接 */
const handleTest = async () => {
if (!ip.value) {
ElMessage.warning('请输入 IP 地址')
return
}
if (!port.value) {
ElMessage.warning('请输入端口号')
return
}
testLoading.value = true
result.value = null
try {
const testRes = await testConnection(ip.value, port.value)
if (testRes.code === 200) {
result.value = {
success: true,
message: testRes.message
}
} else {
result.value = {
success: false,
message: testRes.message
}
}
console.log('测试结果:', testRes)
} catch (error: any) {
let message = '未知错误'
if (error.code === 'ECONNABORTED') {
message = '连接超时,请检查地址是否正确或联系服务供应方'
} else if (error.message) {
message = error.message
}
result.value = {
success: false,
message
}
console.log('测试失败:', error)
} finally {
testLoading.value = false
}
}
/** 绑定账号:登录孔网 + 批量提交 Token 到核价器 + 刷新列表 */
const handleBind = async () => {
if (!ip.value) {
ElMessage.warning('请先填写核价器 IP 地址')
return
}
if (!port.value) {
ElMessage.warning('请先填写核价器端口号')
return
}
bindLoading.value = true
try {
//
const successfulAccounts: { username: string; token: string }[] = []
for (let i = 0; i < accounts.value.length; i++) {
const item = accounts.value[i]
if (item.account && item.password) {
try {
const loginRes = await kongfzLogin(item.account, item.password, ip.value, port.value)
console.log('核价器登录响应:', loginRes)
if (loginRes?.code === 200 && loginRes?.data) {
const { token, nickname: username } = loginRes.data
accounts.value[i].token = token
accounts.value[i].username = username
localStorage.setItem(username, token)
addToSavedList({
account: item.account,
username,
token
})
successfulAccounts.push({ username, token })
console.log(`账号 ${item.account} 登录成功username: ${username}`)
} else {
console.log(`账号 ${item.account} 登录失败:`, loginRes?.message)
}
} catch (loginError: any) {
console.log(`账号 ${item.account} 登录失败:`, loginError.message)
}
}
}
// Token
if (successfulAccounts.length > 0) {
try {
const addRes = await batchAddTokens(successfulAccounts, ip.value, port.value)
console.log('token/add 响应:', addRes)
} catch (addError: any) {
// console.error('token/add :', addError.message)
}
}
//
try {
const res = await fetchTokenList(ip.value, port.value)
const tokenList: { Username: string; Token: string; ID: number; IsEnable: boolean }[] = res?.data
if (Array.isArray(tokenList)) {
const entries: AccountEntry[] = tokenList
.filter(t => t.IsEnable)
.map(t => ({
id: t.ID,
account: t.Username,
username: t.Username,
token: t.Token
}))
saveSavedAccounts(entries)
savedAccountList.value = entries
entries.forEach(e => localStorage.setItem(e.username, e.token))
}
} catch (fetchErr) {
// console.error(' Token :', fetchErr)
}
if (successfulAccounts.length > 0) {
ElMessage.success(`成功绑定 ${successfulAccounts.length} 个账号`)
} else {
ElMessage.info('没有成功绑定的账号,请检查账号密码是否正确')
}
} finally {
bindLoading.value = false
}
}
/** 追加账号绑定:与 handleBind 逻辑一致,但操作 appendAccounts */
const handleAppendBind = async () => {
if (!ip.value) {
ElMessage.warning('请先填写核价器 IP 地址')
return
}
if (!port.value) {
ElMessage.warning('请先填写核价器端口号')
return
}
appendLoading.value = true
try {
const successfulAccounts: { username: string; token: string }[] = []
for (let i = 0; i < appendAccounts.value.length; i++) {
const item = appendAccounts.value[i]
if (item.account && item.password) {
try {
const loginRes = await kongfzLogin(item.account, item.password, ip.value, port.value)
if (loginRes?.code === 200 && loginRes?.data) {
const { token, nickname: username } = loginRes.data
appendAccounts.value[i].token = token
appendAccounts.value[i].username = username
localStorage.setItem(username, token)
addToSavedList({
account: item.account,
username,
token
})
successfulAccounts.push({ username, token })
}
} catch (loginError: any) {
console.log(`追加账号 ${item.account} 登录失败:`, loginError.message)
}
}
}
if (successfulAccounts.length > 0) {
try {
await batchAddTokens(successfulAccounts, ip.value, port.value)
} catch (addError: any) {
// console.error(' token/add :', addError.message)
}
}
//
try {
const res = await fetchTokenList(ip.value, port.value)
const tokenList: { Username: string; Token: string; ID: number; IsEnable: boolean }[] = res?.data
if (Array.isArray(tokenList)) {
const entries: AccountEntry[] = tokenList
.filter(t => t.IsEnable)
.map(t => ({
id: t.ID,
account: t.Username,
username: t.Username,
token: t.Token
}))
saveSavedAccounts(entries)
savedAccountList.value = entries
entries.forEach(e => localStorage.setItem(e.username, e.token))
}
} catch (fetchErr) {
// console.error(' Token :', fetchErr)
}
if (successfulAccounts.length > 0) {
ElMessage.success(`成功追加绑定 ${successfulAccounts.length} 个账号`)
} else {
ElMessage.info('没有成功绑定的账号,请检查账号密码是否正确')
}
//
showAppendForm.value = false
appendAccounts.value = [{ account: '', password: '', token: '', username: '' }]
} finally {
appendLoading.value = false
}
}
/** 保存配置 */
const handleSave = async () => {
if (!ip.value) {
ElMessage.warning('请输入 IP 地址')
return
}
if (!port.value) {
ElMessage.warning('请输入端口号')
return
}
localStorage.setItem(STORAGE_KEY_FILE_DIR, dir.value)
localStorage.setItem(STORAGE_KEY_IP, ip.value)
localStorage.setItem(STORAGE_KEY_PORT, port.value)
localStorage.setItem(STORAGE_KEY_VERIFY_INDEX, verifyIndex.value)
localStorage.setItem('new_price', newPrice.value)
localStorage.setItem('placeholder_down_price', placeholderDownPrice.value)
localStorage.setItem('min_shipping_fee', minShippingFee.value)
localStorage.setItem('min_price', minPrice.value)
console.log('核价配置已保存:', { ip: ip.value, port: port.value, verifyIndex: verifyIndex.value })
const newpriceNum = parseFloat(newPrice.value)
const placeholderDownPriceNum = parseFloat(placeholderDownPrice.value)
const minShippingFeeNum = parseFloat(minShippingFee.value)
const minPriceNum = parseFloat(minPrice.value)
if (!isNaN(newpriceNum) && newpriceNum >= 0) {
const sendNewPrice = newpriceNum
const sendPlaceholderDownPrice = !isNaN(placeholderDownPriceNum) && placeholderDownPriceNum >= 0
? placeholderDownPriceNum
: 0.01
const sendMinShippingFee = !isNaN(minShippingFeeNum) && minShippingFeeNum >= 0
? minShippingFeeNum
: 3.00
const sendMinPrice = !isNaN(minPriceNum) && minPriceNum >= 0
? minPriceNum
: 1.00
try {
await saveNewPrice(ip.value, port.value, sendNewPrice, sendPlaceholderDownPrice, sendMinShippingFee, sendMinPrice,verifyIndex.value)
console.log('核价价格相关配置已保存')
} catch (err: any) {
console.log('核价价格相关配置保存失败:', err.message)
}
}
ElMessage.success(`配置已保存IP=${ip.value}, PORT=${port.value}, 核价位置=${verifyIndex.value}`)
}
/** 从服务器获取已保存的 Token 列表并同步到本地 */
const fetchSavedTokens = async (): Promise<void> => {
try {
const res = await fetchTokenList(ip.value, port.value)
const tokenList: { Username: string; Token: string; ID: number; IsEnable: boolean }[] = res?.data
if (Array.isArray(tokenList) && tokenList.length > 0) {
const entries: AccountEntry[] = tokenList
.filter(t => t.IsEnable)
.map(t => ({
id: t.ID,
account: t.Username,
username: t.Username,
token: t.Token
}))
saveSavedAccounts(entries)
savedAccountList.value = entries
// token localStorage key
entries.forEach(e => localStorage.setItem(e.username, e.token))
}
} catch (err) {
console.log('获取已保存 Token 列表失败(首次使用可忽略):', err)
}
}
/** 从核价器拉取价格配置,补全 localStorage 中缺失的字段 */
const loadPriceConfig = async (): Promise<void> => {
try {
const res = await fetchConfig(ip.value, port.value)
const data = res?.data
if (data) {
//
const fieldMap: { respKey: string; storeKey: string }[] = [
{ respKey: 'QueryIndex', storeKey: 'verify_index' },
{ respKey: 'NewPrice', storeKey: 'new_price' },
{ respKey: 'PlaceholderDownPrice', storeKey: 'placeholder_down_price' },
{ respKey: 'MinShippingFee', storeKey: 'min_shipping_fee' },
{ respKey: 'MinPrice', storeKey: 'min_price' },
]
console.log('从服务器获取的核价配置:', data)
for (const f of fieldMap) {
const val = (data as any)[f.respKey]
if (val !== undefined && val !== null) {
localStorage.setItem(f.storeKey, String(val))
console.log(`loadPriceConfig: 同步 ${f.storeKey} = ${val}`)
}
}
// ref
verifyIndex.value = localStorage.getItem(STORAGE_KEY_VERIFY_INDEX) || verifyIndex.value
newPrice.value = fmt(localStorage.getItem('new_price'), '0.00')
placeholderDownPrice.value = fmt(localStorage.getItem('placeholder_down_price'), '0.01')
minShippingFee.value = fmt(localStorage.getItem('min_shipping_fee'), '3.00')
minPrice.value = fmt(localStorage.getItem('min_price'), '1.00')
// Port
if (!port.value && (data as any).Port) {
port.value = String((data as any).Port)
}
}
} catch (err) {
console.log('获取核价器配置失败(首次使用可忽略):', err)
}
}
// + +
onMounted(() => {
loadSavedAccountsList()
fetchSavedTokens()
loadPriceConfig()
})
return {
dir,
ip,
port,
verifyIndex,
newPrice,
placeholderDownPrice,
minShippingFee,
minPrice,
accounts,
addAccount,
removeAccount,
showAppendForm,
appendAccounts,
appendLoading,
addAppendAccount,
removeAppendAccount,
cancelAppend,
enforceMinPlaceholderDownPrice,
onPriceInput,
onPriceBlur,
handleAppendBind,
testLoading,
bindLoading,
result,
savedAccountList,
deleteSavedAccount,
handleTest,
handleBind,
handleSave,
handleOpenExe,
Delete,
Plus,
Setting,
User,
Link,
VideoPlay
}
}
}
</script>
<style scoped lang="scss">
.config-page {
padding: 20px;
max-width: 860px;
margin: 0 auto;
}
.config-card {
border-radius: 12px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
}
.config-row {
display: flex;
gap: 16px;
.flex-item {
flex: 1;
min-width: 0;
}
}
.save-bar {
display: flex;
}
.result-box {
margin-top: 16px;
}
/* 账号区域 */
.account-section {
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 16px;
}
.count-tag {
margin-left: 0;
}
}
.account-form {
.account-row {
margin-bottom: 16px;
.account-label {
font-size: 14px;
color: #606266;
margin-bottom: 8px;
font-weight: 500;
}
.account-inputs {
display: flex;
gap: 8px;
align-items: center;
}
}
}
.add-btn {
margin-top: 4px;
}
.bind-bar {
margin-top: 16px;
}
.append-bar {
margin-top: 16px;
}
.append-form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.token-text {
font-family: 'Courier New', monospace;
font-size: 12px;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 响应式 */
@media (max-width: 640px) {
.config-row {
flex-direction: column;
gap: 0;
}
.account-inputs {
flex-wrap: wrap;
}
}
.label-with-icon {
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>

View File

@ -0,0 +1,369 @@
<template>
<el-popover
placement="right-start"
:width="360"
trigger="hover"
:open-delay="500"
:close-delay="100"
:disabled="!isbn"
@show="handleShow"
@hide="handleHide"
>
<template #reference>
<span class="isbn-popover-trigger" :class="{ 'is-loading': loading }">
<slot />
</span>
</template>
<!-- 加载中 -->
<div v-if="loading" class="popover-loading">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>正在查询书品信息...</span>
</div>
<!-- 查询失败 -->
<div v-else-if="error" class="popover-error">
<el-icon :size="24" color="#e6a23c"><WarningFilled /></el-icon>
<span>{{ error }}</span>
</div>
<!-- 查询成功 展示书品信息 -->
<div v-else-if="bookData" class="popover-content">
<div class="popover-header">
<span class="popover-isbn">{{ isbn }}</span>
<span v-if="bookData.isSuit" class="suit-badge">套装书</span>
</div>
<div class="popover-body">
<!-- 左侧封面图片 -->
<div class="popover-cover">
<img
v-if="bookData.book_pic?.pddPath"
:src="bookData.book_pic.pddPath"
alt="封面"
class="cover-image"
/>
<div v-else class="cover-placeholder">
<el-icon :size="32"><Picture /></el-icon>
</div>
</div>
<!-- 右侧书籍详情 -->
<div class="popover-info">
<div class="info-row">
<span class="info-label">书名</span>
<span class="info-value info-value-name">{{ bookData.bookName || '未知' }}</span>
</div>
<div class="info-row">
<span class="info-label">作者</span>
<span class="info-value">{{ bookData.author || '未知' }}</span>
</div>
<div class="info-row">
<span class="info-label">出版社</span>
<span class="info-value">{{ bookData.publisher || '未知' }}</span>
</div>
<div class="info-row">
<span class="info-label">出版时间</span>
<span class="info-value">{{ bookData.publishDate || '未知' }}</span>
</div>
<div class="info-row">
<span class="info-label">装帧</span>
<span class="info-value">{{ bookData.binding || '未知' }}</span>
</div>
<div class="info-row">
<span class="info-label">定价</span>
<span class="info-value info-value-price">¥{{ formatPrice(bookData.price) }}</span>
</div>
<div class="info-row">
<span class="info-label">页数</span>
<span class="info-value">{{ bookData.pageCount ?? '未知' }}</span>
</div>
<div class="info-row">
<span class="info-label">字数</span>
<span class="info-value">{{ bookData.wordCount ?? '未知' }}</span>
</div>
</div>
</div>
</div>
<!-- ISBN 为空 -->
<div v-else class="popover-empty">
<span>暂无ISBN</span>
</div>
</el-popover>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loading, WarningFilled, Picture } from '@element-plus/icons-vue'
import request from '@/utils/request'
interface BookPic {
localPath?: string
pddPath?: string
}
interface BookInfoResult {
bookName: string
author: string
publisher: string
publishDate: string
binding: string
price: number
pageCount: number
wordCount: number
book_pic?: BookPic
isSuit: boolean
}
const props = defineProps<{
/** ISBN 编号 */
isbn?: string
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const bookData = ref<BookInfoResult | null>(null)
// ISBN
const cache = new Map<string, BookInfoResult>()
/** 格式化出版时间:处理年月日格式 */
function formatPublishDate(value: string | number | undefined | null): string {
if (value == null || value === '') return ''
const str = String(value)
// yyyy-mm-dd
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str
// yyyyMMdd 8
if (/^\d{8}$/.test(str)) {
return `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}`
}
// yyyy-mm
if (/^\d{4}-\d{2}$/.test(str)) return `${str}-01`
//
const num = Number(value)
if (!isNaN(num) && num > 10000) {
const d = new Date(num * 1000)
if (!isNaN(d.getTime())) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
}
return str
}
/** 格式化价格:分 → 元 */
function formatPrice(priceInCents: number): string {
if (priceInCents == null) return '0.00'
return (priceInCents / 100).toFixed(2)
}
async function fetchBookInfo(isbn: string) {
//
if (cache.has(isbn)) {
bookData.value = cache.get(isbn)!
return
}
loading.value = true
error.value = null
bookData.value = null
try {
const payload = await request.get('/getBookInfo', {
params: { isbn }
})
const data = payload?.data
if (!data) {
error.value = '数据库中暂无该书数据'
return
}
const result: BookInfoResult = {
bookName: data.book_name || '',
author: data.author || '',
publisher: data.publisher || '',
publishDate: formatPublishDate(data.publication_time),
binding: data.binding_layout || '',
price: typeof data.fix_price === 'number' ? data.fix_price : 0,
pageCount: Number(data.page_count) || 0,
wordCount: Number(data.word_count) || 0,
book_pic: data.book_pic || undefined,
isSuit: data.is_suit === 1
}
//
cache.set(isbn, result)
bookData.value = result
} catch (err) {
console.warn('[goodsPop] 书籍信息查询失败:', err instanceof Error ? err.message : String(err))
error.value = '查询失败,请稍后重试'
} finally {
loading.value = false
}
}
function handleShow() {
if (!props.isbn) return
fetchBookInfo(props.isbn)
}
function handleHide() {
// 便
}
</script>
<style scoped>
.isbn-popover-trigger {
cursor: pointer;
border-bottom: 1px dashed #409eff;
transition: all 0.2s;
}
.isbn-popover-trigger:hover {
color: #409eff;
}
.isbn-popover-trigger.is-loading {
opacity: 0.7;
}
/* 加载状态 */
.popover-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
justify-content: center;
color: #909399;
font-size: 13px;
}
.popover-loading .is-loading {
animation: rotating 1.5s linear infinite;
}
@keyframes rotating {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 错误状态 */
.popover-error {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
justify-content: center;
color: #e6a23c;
font-size: 13px;
}
/* 空状态 */
.popover-empty {
padding: 20px;
text-align: center;
color: #c0c4cc;
font-size: 13px;
}
/* 内容主体 */
.popover-content {
padding: 4px;
}
.popover-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.popover-isbn {
font-size: 13px;
font-weight: 600;
color: #303133;
font-family: 'Courier New', monospace;
}
.suit-badge {
display: inline-block;
padding: 1px 8px;
font-size: 11px;
color: #e6a23c;
background: #fdf6ec;
border: 1px solid #f5dab1;
border-radius: 4px;
line-height: 1.6;
}
.popover-body {
display: flex;
gap: 14px;
}
/* 封面 */
.popover-cover {
flex-shrink: 0;
width: 90px;
height: 120px;
border-radius: 4px;
overflow: hidden;
border: 1px solid #ebeef5;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
color: #c0c4cc;
}
/* 详情 */
.popover-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.info-row {
display: flex;
font-size: 12px;
line-height: 1.6;
}
.info-label {
flex-shrink: 0;
color: #909399;
width: 56px;
text-align: right;
margin-right: 4px;
}
.info-value {
flex: 1;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-value-name {
font-weight: 600;
color: #409eff;
}
.info-value-price {
color: #f56c6c;
font-weight: 600;
}
</style>

View File

View File

@ -0,0 +1,93 @@
import request from '@/utils/request'
/** 发货单 API 基础路径 */
const API_BASE = '/shipping-order'
/**
* 标准化列表接口返回的数据格式
* @param {Object} payload - 接口返回的原始响应对象
* @returns {{ list: Array, total: number }} 标准化后的列表数据
*/
const normalizeListResponse = (payload) => {
const data = payload?.data
if (!data) return { list: [], total: 0 }
if (Array.isArray(data)) return { list: data, total: data.length }
return {
list: Array.isArray(data.list) ? data.list : [],
total: typeof data.total === 'number' ? data.total : 0
}
}
/**
* 获取发货单列表支持分页和筛选
* @param {Object} params - 请求参数
* @param {string} [params.check_no] - 搜索关键字发货单号
* @param {number} [params.status] - 状态筛选
* @param {number} [params.customer_id] - 客户ID筛选
* @param {number} [params.warehouse_id] - 仓库ID筛选
* @param {number} [params.sales_order_id] - 销售订单ID筛选
* @param {number} [params.wave_task_id] - 波次任务ID筛选
* @param {number} [params.page] - 当前页码
* @param {number} [params.pageSize] - 每页条数
* @returns {Promise<{ list: Array, total: number }>} 标准化后的发货单列表
*/
export const fetchShippingOrderList = async ({ check_no, status, customer_id, warehouse_id, sales_order_id, wave_task_id, page, pageSize }) => {
const params = {
check_no: check_no || undefined,
status,
customer_id,
warehouse_id,
sales_order_id,
wave_task_id,
page,
page_size: pageSize
}
const response = await request.get(`${API_BASE}/list`, { params })
return normalizeListResponse(response)
}
/**
* 获取单个发货单详情含明细行
* @param {string|number} id - 发货单ID
* @returns {Promise<Object|null>} 发货单详情对象
*/
export const fetchShippingOrderDetail = async (id) => {
const response = await request.get(`${API_BASE}/detail`, { params: { id } })
return response?.data || null
}
/**
* 生成发货单form-data 格式
* @param {Object} params
* @param {number} params.total - 选中的出库单数量
* @param {number[]} params.outbound_order_ids - 选中的出库单 ID 数组
* @returns {Promise}
*/
export const createShippingOrder = async ({ total, outbound_order_ids }) => {
// 手动构建 FormData按照 outbound_order_ids[0]={id} 的格式
const formData = new FormData()
formData.append('total', String(total))
outbound_order_ids.forEach((id, index) => {
formData.append(`order_ids[${index}]`, String(id))
})
return request.post(`${API_BASE}/create`, formData)
}
/**
* 更新发货单物流信息form-data 格式
* @param {Object} params
* @param {number} params.shipping_order_id - 发货单ID
* @param {number} params.sales_order_item_id - 销售订单明细ID
* @param {string} params.logistics_company - 物流公司
* @param {string} params.logistics_no - 物流单号
* @returns {Promise}
*/
export const updateShippingOrderLogistics = async ({ shipping_order_id, sales_order_item_id, logistics_company, logistics_no }) => {
const formData = new FormData()
formData.append('shipping_order_id', String(shipping_order_id))
formData.append('total', '1')
formData.append('sales_order_item_id', String(sales_order_item_id))
formData.append('logistics_company', logistics_company)
formData.append('logistics_no', logistics_no)
return request.post(`${API_BASE}/update`, formData)
}

50
src/api/book.js Normal file
View File

@ -0,0 +1,50 @@
import request from '@/utils/request'
/**
* 根据 ISBN 查询书籍信息
* @param {string} isbn - ISBN 编号
* @returns {Promise<Object>} 接口返回的 Promise 对象包含书籍信息
*/
export const getBookInfo = async (isbn) => {
return request.get('/getBookInfo', { params: { isbn } })
}
/**
* 同步/保存书籍信息
* @param {Object} params - 书籍数据
* @param {string} [params.book_name] - 书名
* @param {string} [params.author] - 作者
* @param {string} [params.publisher] - 出版社
* @param {string} [params.publication_time] - 出版时间
* @param {string} [params.binding_layout] - 装帧
* @param {number} [params.fix_price] - 定价
* @param {string} [params.isbn] - ISBN
* @param {string|number} [params.page_count] - 页数
* @param {string|number} [params.word_count] - 字数
* @param {string} [params.book_format] - 开本
* @param {number} [params.fid] - 上级ID
* @param {string} [params.f_isbn] - 上级ISBN
* @param {string} [params.f_book_name] - 上级书名
* @param {string} [params.live_image] - 实拍图片
* @param {string|number} [params.type] - 类型
* @returns {Promise}
*/
export const syncBook = async (params) => {
const body = {}
if (params.book_name) body['book_name'] = params.book_name
if (params.author) body['author'] = params.author
if (params.publisher) body['publisher'] = params.publisher
if (params.publication_time) body['publication_time'] = params.publication_time
if (params.binding_layout) body['binding_layout'] = params.binding_layout
if (params.fix_price !== undefined) body['fix_price'] = params.fix_price
if (params.isbn) body['isbn'] = params.isbn
if (params.page_count) body['page_count'] = params.page_count
if (params.word_count) body['word_count'] = params.word_count
if (params.book_format) body['book_format'] = params.book_format
if (params.fid !== undefined) body['fid'] = params.fid
if (params.f_isbn) body['f_isbn'] = params.f_isbn
if (params.f_book_name) body['f_book_name'] = params.f_book_name
if (params.live_image) body['live_image[0]'] = params.live_image
if (params.type) body['type'] = params.type
return request.post('/syncBook', body)
}

View File

@ -94,4 +94,48 @@ export const fetchConfig = (ip, port) => {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
}).then(res => res.data)
}
/**
* 向核价器查询商品信息
* @param {Object} params - 查询参数
* @param {string} params.ip - 核价器 IP
* @param {string} params.port - 核价器端口
* @param {string} params.isbn - ISBN
* @param {string|number} params.outId - 商品外部ID
* @param {string|number} params.quality - 品质
* @param {string|number} params.queryIndex - 查询索引
* @param {string|number} params.userId - 用户ID
* @param {string} [params.placeholderDownPrice] - 占位降价金额
* @param {string} [params.minShippingFee] - 最低运费
* @param {string} [params.minPrice] - 最低书价
* @returns {Promise}
*/
export const queryGoodsPrice = ({ ip, port, isbn, outId, quality, queryIndex, userId, placeholderDownPrice, minShippingFee, minPrice }) => {
const params = new URLSearchParams()
params.append('isbn', isbn)
params.append('out_id', String(outId))
params.append('quality', String(quality))
params.append('query_index', String(queryIndex))
params.append('user_id', String(userId))
if (placeholderDownPrice) params.append('placeholder_down_price', placeholderDownPrice)
if (minShippingFee) params.append('min_shipping_fee', minShippingFee)
if (minPrice) params.append('min_price', minPrice)
return axios.post(`http://${ip}:${port}/api/goods/query`, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 10000
}).then(res => res.data)
}
/**
* 删除核价器中的 Token
* @param {string} ip - 核价器 IP
* @param {string} port - 核价器端口
* @param {string|number} id - Token ID
* @returns {Promise}
*/
export const deleteToken = (ip, port, id) => {
return axios.get(`http://${ip}:${port}/api/token/delete`, {
params: { id }
}).then(res => res.data)
}

62
src/api/employee.js Normal file
View File

@ -0,0 +1,62 @@
import request from '@/utils/request'
/**
* 获取员工/代理列表
* @param {Object} params - 查询参数
* @param {number} [params.page] - 当前页码
* @param {number} [params.page_size] - 每页条数
* @param {number|string} [params.status] - 状态筛选
* @param {string} [params.keyword] - 搜索关键词工号/姓名/账号
* @returns {Promise<{ code: number, data: { list: Array, total: number } }>}
*/
export const fetchEmployeeList = async (params) => {
return request.get('/admin/employee/list', { params })
}
/**
* 添加员工/代理
* @param {Object} params - 添加参数
* @param {string} params.name - 姓名
* @param {string} params.password - 密码
* @param {string} params.phone - 手机号
* @param {number} [params.fid] - 上级ID
* @param {number} [params.about_id] - about_id
* @returns {Promise}
*/
export const addEmployee = async (params) => {
return request.post('/admin/employee/add', params)
}
/**
* 代理积分充值
* @param {string|number} employeeId - 员工ID
* @param {Object} data - 充值数据
* @param {number} data.amount - 充值数量
* @param {string} [data.remark] - 备注
* @returns {Promise}
*/
export const topupEmployee = async (employeeId, data) => {
return request.post(`/admin/employee/topup/${employeeId}`, data)
}
/**
* 代理积分扣减
* @param {string|number} employeeId - 员工ID
* @param {Object} data - 扣减数据
* @param {number} data.amount - 扣减数量
* @param {string} [data.remark] - 备注
* @returns {Promise}
*/
export const deductEmployee = async (employeeId, data) => {
return request.post(`/admin/employee/deduct/${employeeId}`, data)
}
/**
* 启用/禁用代理
* @param {'enable'|'disable'} action - 操作类型
* @param {string|number} employeeId - 员工ID
* @returns {Promise}
*/
export const toggleEmployeeStatus = async (action, employeeId) => {
return request.post(`/admin/employee/${action}/${employeeId}`)
}

12
src/api/login.js Normal file
View File

@ -0,0 +1,12 @@
import request from '@/utils/request'
/**
* 登录
* @param {'admin'|'employee'} type - 登录类型'admin' 对应管理员(255)'employee' 对应代理(128)
* @param {Object} formData - 表单数据 { username, password }
* @returns {Promise}
*/
export const login = async (type, formData) => {
const role = type === 'admin' ? '255' : '128'
return request.post(`/login/${role}`, { ...formData, type: 1 })
}

View File

@ -0,0 +1,49 @@
import request from '@/utils/request'
/**
* 标准化列表接口返回的数据格式
*/
const normalizeListResponse = (payload) => {
const data = payload?.data
if (!data) {
return { list: [], total: 0 }
}
if (Array.isArray(data)) {
return { list: data, total: data.length }
}
return {
list: Array.isArray(data.list) ? data.list : [],
total: typeof data.total === 'number' ? data.total : Array.isArray(data.list) ? data.list.length : 0
}
}
/**
* 获取书目异常记录列表
* @param {Object} params - 查询参数
* @param {string} [params.barcode] - ISBN
* @param {number|string} [params.status] - 状态筛选
* @param {number} [params.page] - 当前页码
* @param {number} [params.page_size] - 每页条数
* @returns {Promise<{ list: Array, total: number }>}
*/
export const fetchProductLogList = async (params) => {
const res = await request.get('/product_log/list', { params })
const data = res?.data
return {
list: Array.isArray(data?.list) ? data.list : Array.isArray(data) ? data : [],
total: data?.total || 0
}
}
/**
* 删除书目异常记录
* @param {string|number} id - 记录ID
* @returns {Promise}
*/
export const deleteProductLog = async (id) => {
const formData = new FormData()
formData.append('id', String(id))
return request.post('/product_log/delete', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}

View File

@ -1,8 +1,13 @@
import request from '@/utils/request'
import axios from 'axios'
/** 发货单 API 基础路径 */
const API_BASE = '/shipping-order'
// 外部 API 基础地址
const BZY_API_BASE = 'https://api.buzhiyushu.cn'
const PRINT_API_BASE = 'https://print.buzhiyushu.cn'
/**
* 标准化列表接口返回的数据格式
* @param {Object} payload - 接口返回的原始响应对象
@ -91,3 +96,76 @@ export const updateShippingOrderLogistics = async ({ shipping_order_id, sales_or
formData.append('logistics_no', logistics_no)
return request.post(`${API_BASE}/update`, formData)
}
// ───────── 外部快递/打印相关 API ─────────
/**
* 获取快递账号列表外部接口
* @param {string|number} shopId - 店铺ID
* @returns {Promise<Object>}
*/
export const fetchFastMailList = async (shopId) => {
const res = await axios.get(`${BZY_API_BASE}/zhishu/fastMail/listApi`, {
params: { shopId }
})
return res.data
}
/**
* 创建快递面单外部打印接口
* @param {Object} params - 面单参数
* @param {string} params.type - 快递类型
* @param {string} params.partnerId - 合作方ID
* @param {string} params.secret - 密钥
* @param {string} params.orderSn - 平台单号
* @param {string} params.contact - 联系人
* @param {string} params.phoneNumber - 联系电话
* @param {string} params.province - 省份
* @param {string} params.city - 城市
* @param {string} params.area - 区县
* @param {string} params.town - 详细地址
* @returns {Promise<Object>}
*/
export const createOrderBatch = async (params) => {
const formData = new FormData()
formData.append('type', params.type)
formData.append('partnerId', params.partnerId)
formData.append('secret', params.secret)
formData.append('orderSn', params.orderSn)
formData.append('contact', params.contact)
formData.append('phoneNumber', params.phoneNumber)
formData.append('province', params.province)
formData.append('city', params.city)
formData.append('area', params.area)
formData.append('town', params.town)
const res = await axios.post(`${PRINT_API_BASE}/api/print/createOrderBatch`, formData)
return res.data
}
/**
* 获取打印面单信息外部打印接口
* @param {Object} params - 参数
* @param {string} params.mailno - 快递单号
* @param {string} params.partnerId - 合作方ID
* @param {string} params.secret - 密钥
* @returns {Promise<Object>}
*/
export const createBmOrderDaYin = async (params) => {
const res = await axios.get(`${PRINT_API_BASE}/api/print/createBmOrderDaYin`, { params })
return res.data
}
/**
* 回填快递单号外部接口
* @param {Object} params - 参数
* @param {string} params.code - 快递公司编码
* @param {string} params.orderNo - 快递单号
* @param {string} params.erpOrderId - ERP 订单ID
* @returns {Promise<Object>}
*/
export const submitCompanyOrder = async (params) => {
const res = await axios.post(`${BZY_API_BASE}/zhishu/orderExternalGoods/submitCompanyOrder`, params, {
headers: { 'Content-Type': 'application/json' }
})
return res.data
}

View File

@ -136,3 +136,12 @@ export const createWaveOutboundRelease = async (relatedOrderId, waveId) => {
formData.append('wave_id', waveId)
return request.post('/wave/outbound/release', formData)
}
/**
* 查询波次状态
* @param {string|number} id
* @returns
*/
export const getWaveStatusById = async (id) => {
return request.get(`/wave/getWaveStatusById?id=${id}`)
}

View File

@ -26,7 +26,7 @@
</template>
<el-menu-item index="/admin/employees" @click="goTo('/admin/employees')">代理列表</el-menu-item>
<el-menu-item index="/admin/employees/add" @click="goTo('/admin/employees/add')">添加代理</el-menu-item>
<el-menu-item idnex="/admin/employee-type" @click="goTo('/admin/employee-type')">账户类型</el-menu-item>
<el-menu-item index="/admin/employee-type" @click="goTo('/admin/employee-type')">账户类型</el-menu-item>
</el-sub-menu>
<!-- 以下菜单 - 所有登录用户可见 -->

View File

@ -96,7 +96,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loading, WarningFilled, Picture } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { getBookInfo } from '@/api/book'
interface BookPic {
localPath?: string
@ -169,9 +169,7 @@ async function fetchBookInfo(isbn: string) {
bookData.value = null
try {
const payload = await request.get('/getBookInfo', {
params: { isbn }
})
const payload = await getBookInfo(isbn)
const data = payload?.data
if (!data) {

View File

@ -108,6 +108,9 @@ import type { GoodsInfo } from './goodsInfo.vue'
import SuitBookDialog from './suitBookDialog.vue'
import OcrResultDialog from './OcrResultDialog.vue'
import { ocrImage } from '@/api/product'
import { generateBarcode } from '@/api/barcode'
import { getBookInfo, syncBook } from '@/api/book'
import { queryGoodsPrice } from '@/api/config'
import { getAdminUserInfo } from '@/utils/auth'
import axios from 'axios'
@ -244,28 +247,24 @@ async function queryGoodsApi(isbn: string, productId: number, quality: string):
const ip = localStorage.getItem('test_ip') || '127.0.0.1'
const port = localStorage.getItem('test_port') || '8080'
const formData = new URLSearchParams()
formData.append('isbn', isbn)
formData.append('out_id', productId.toString())
formData.append('quality', quality.toString())
formData.append('query_index', queryIndex.toString())
formData.append('user_id', userId.toString())
formData.append('placeholder_down_price', localStorage.getItem('placeholder_down_price') || '0.01')
formData.append('min_shipping_fee', localStorage.getItem('min_shipping_fee') || '5.00')
formData.append('min_price', localStorage.getItem('min_price') || '1.00')
console.log('[goods/query] 发送请求 - 直连:', { ip, port, isbn, productId, quality })
try {
// CORS
const response = await axios.post(`http://${ip}:${port}/api/goods/query`, formData.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
timeout: 10000
const response = await queryGoodsPrice({
ip,
port,
isbn,
outId: productId,
quality,
queryIndex,
userId,
placeholderDownPrice: localStorage.getItem('placeholder_down_price') || '0.01',
minShippingFee: localStorage.getItem('min_shipping_fee') || '5.00',
minPrice: localStorage.getItem('min_price') || '1.00'
})
console.log('[goods/query] 响应:', response.data)
console.log('[goods/query] 响应:', response)
} catch (err) {
// console.error('[goods/query] :', err instanceof Error ? err.message : String(err))
//
@ -1778,12 +1777,8 @@ async function generateWaveBarcode() {
barcodeLoading.value = true
try {
const request = (await import('@/utils/request')).default
const res = await request.post('/barcode/generate', {
content: waveNo.value
})
const res = await generateBarcode(waveNo.value)
// res response.data, AxiosResponse
const resData = res as any
const imageBase64 = resData.data?.image_base64
if (resData.code === 200 && imageBase64) {
@ -2303,10 +2298,7 @@ async function loadBookInfo(isbn: string) {
lastScannedIsbn.value = isbn
try {
const request = (await import('@/utils/request')).default
const payload = await request.get('/getBookInfo', {
params: { isbn }
})
const payload = await getBookInfo(isbn)
const data = payload?.data
if (!data) {
bookInfo.value = null
@ -2878,25 +2870,7 @@ async function callSyncBookApi(params: {
type?: string
}) {
try {
const request = (await import('@/utils/request')).default
// objectToFormData Content-Type
const body: Record<string, any> = {}
if (params.book_name) body['book_name'] = params.book_name
if (params.author) body['author'] = params.author
if (params.publisher) body['publisher'] = params.publisher
if (params.publication_time) body['publication_time'] = params.publication_time
if (params.binding_layout) body['binding_layout'] = params.binding_layout
if (params.fix_price !== undefined) body['fix_price'] = params.fix_price
if (params.isbn) body['isbn'] = params.isbn
if (params.page_count) body['page_count'] = params.page_count
if (params.word_count) body['word_count'] = params.word_count
if (params.book_format) body['book_format'] = params.book_format
if (params.fid !== undefined) body['fid'] = params.fid
if (params.f_isbn) body['f_isbn'] = params.f_isbn
if (params.f_book_name) body['f_book_name'] = params.f_book_name
if (params.live_image) body['live_image[0]'] = params.live_image
if (params.type) body['type'] = params.type
const res = await request.post('/syncBook', body)
const res = await syncBook(params)
console.log('[syncBook] 同步成功:', res)
return res
} catch (err) {

View File

@ -13,8 +13,8 @@ const USE_MOCK = false // 设置为true使用模拟数据
// 创建axios实例
const request = axios.create({
// baseURL: import.meta.env.DEV ? '/api' : (import.meta.env.VITE_API_BASE || 'http://192.168.101.213:9090/api'),
baseURL: import.meta.env.DEV ? '/api' : (import.meta.env.VITE_API_BASE || 'https://psi.api.buzhiyushu.cn/api'),
baseURL: import.meta.env.DEV ? '/api' : (import.meta.env.VITE_API_BASE || 'http://192.168.101.213:9090/api'),
// baseURL: import.meta.env.DEV ? '/api' : (import.meta.env.VITE_API_BASE || 'https://psi.api.buzhiyushu.cn/api'),
timeout: 10000,
// 用 JSONbig 替代默认 JSON.parse保留大整数精度
transformResponse: [

View File

@ -176,8 +176,8 @@ const loadLatestProducts = async () => {
productsLoading.value = true
try {
//
startOfDay = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
endOfDay = Math.floor(new Date().setHours(23, 59, 59, 999) / 1000)
const startOfDay = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
const endOfDay = Math.floor(new Date().setHours(23, 59, 59, 999) / 1000)
console.log('查询最新商品,时间范围:', new Date(startOfDay * 1000), ' - ', new Date(endOfDay * 1000))
const data = await fetchLatestProducts(20, startOfDay, endOfDay)
latestProducts.value = data

View File

@ -129,7 +129,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, Back, DocumentCopy } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { fetchEmployeeList, addEmployee } from '@/api/employee'
import { copyToClipboard } from '@/utils/clipboard'
import { useUserStore } from '@/store/user'
@ -200,7 +200,7 @@ const fetchNextEmployeeId = async () => {
try {
//
// 使
const res = await request.get('/admin/employee/list', { params: { page: 1, page_size: 1 } })
const res = await fetchEmployeeList({ page: 1, page_size: 1 })
if (res.code === 200 && res.data.list.length > 0) {
const lastId = res.data.list[0].employee_id
const seq = parseInt(lastId) + 1
@ -243,7 +243,7 @@ const submitForm = async () => {
if (currentInfo?.about_id) {
params.about_id = currentInfo.about_id
}
const res = await request.post('/admin/employee/add', params)
const res = await addEmployee(params)
if (res.code === 200) {
resultData.value = res.data

View File

@ -199,7 +199,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Coin, DataLine, Switch, Check } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { fetchEmployeeList, topupEmployee, deductEmployee, toggleEmployeeStatus } from '@/api/employee'
const router = useRouter() // router
const loading = ref(false)
@ -267,7 +267,7 @@ const formatDate = (timestamp) => {
}
//
const fetchEmployeeList = async () => {
const loadEmployeeList = async () => {
loading.value = true
try {
const params = {
@ -281,7 +281,7 @@ const fetchEmployeeList = async () => {
params.keyword = queryParams.keyword
}
const res = await request.get('/admin/employee/list', { params })
const res = await fetchEmployeeList(params)
if (res.code === 200) {
employeeList.value = res.data.list
total.value = res.data.total
@ -296,7 +296,7 @@ const fetchEmployeeList = async () => {
//
const handleSearch = () => {
queryParams.page = 1
fetchEmployeeList()
loadEmployeeList()
}
//
@ -304,19 +304,19 @@ const resetSearch = () => {
queryParams.status = ''
queryParams.keyword = ''
queryParams.page = 1
fetchEmployeeList()
loadEmployeeList()
}
//
const handleSizeChange = (size) => {
queryParams.page_size = size
fetchEmployeeList()
loadEmployeeList()
}
//
const handleCurrentChange = (page) => {
queryParams.page = page
fetchEmployeeList()
loadEmployeeList()
}
//
@ -331,14 +331,14 @@ const showTopUp = (row) => {
const submitTopUp = async () => {
topUpLoading.value = true
try {
const res = await request.post(`/admin/employee/topup/${currentEmployee.value.employee_id}`, {
const res = await topupEmployee(currentEmployee.value.employee_id, {
amount: topUpForm.amount,
remark: topUpForm.remark
})
if (res.code === 200) {
ElMessage.success('充值成功')
topUpVisible.value = false
fetchEmployeeList() //
loadEmployeeList() //
}
} catch (error) {
// console.error(':', error)
@ -358,14 +358,14 @@ const showDeduct = (row) => {
const submitDeduct = async () => {
deductLoading.value = true
try {
const res = await request.post(`/admin/employee/deduct/${currentEmployee.value.employee_id}`, {
const res = await deductEmployee(currentEmployee.value.employee_id, {
amount: deductForm.amount,
remark: deductForm.remark
})
if (res.code === 200) {
ElMessage.success('扣减成功')
deductVisible.value = false
fetchEmployeeList() //
loadEmployeeList() //
}
} catch (error) {
// console.error(':', error)
@ -383,10 +383,10 @@ const toggleStatus = async (row, action) => {
type: 'warning'
})
const res = await request.post(`/admin/employee/${action}/${row.employee_id}`)
const res = await toggleEmployeeStatus(action, row.employee_id)
if (res.code === 200) {
ElMessage.success(`${actionText}成功`)
fetchEmployeeList() //
loadEmployeeList() //
}
} catch (error) {
if (error !== 'cancel') {
@ -408,7 +408,7 @@ const viewAccess = (row) => {
//
onMounted(() => {
fetchEmployeeList()
loadEmployeeList()
})
</script>

View File

@ -289,6 +289,7 @@ import {
fetchTokenList,
saveNewPrice,
fetchConfig,
deleteToken,
} from '@/api/config'
const STORAGE_KEY_FILE_DIR = 'file_dir'
@ -444,9 +445,7 @@ export default {
type: 'warning'
})
//
await axios.get(`http://${ip.value}:${port.value}/api/token/delete`, {
params: { id: entry.id }
})
await deleteToken(ip.value, port.value, entry.id!)
// username localStorage key
localStorage.removeItem(entry.username)
const list = loadSavedAccounts().filter(a => a.account !== entry.account)

View File

@ -72,7 +72,7 @@
User,
Lock
} from '@element-plus/icons-vue'
import request from '@/utils/request'
import { login } from '@/api/login'
import {
setAdminToken,
setAdminUserInfo
@ -132,8 +132,7 @@
loading.value = true
try {
const type = activeTab.value === 'admin' ? '255' : '128'
const res = await request.post(`/login/${type}`, { ...formData, type: 1 })
const res = await login(activeTab.value, { ...formData })
if (res.code === 200) {
setAdminToken(res.data.token)

View File

@ -217,7 +217,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import { fetchProductLogList, deleteProductLog } from '@/api/reviewIllegalBook'
import SubmIllegalBook from '@/components/dialog/submIllegalBook/SubmIllegalBook.vue'
//
@ -334,22 +334,14 @@ function handlePageChange() {
async function fetchData() {
loading.value = true
try {
const res = await request.get('/product_log/list', {
params: {
barcode: searchForm.isbn || undefined,
status: searchForm.status || undefined,
page: pagination.current,
page_size: pagination.pageSize
}
const { list, total } = await fetchProductLogList({
barcode: searchForm.isbn || undefined,
status: searchForm.status || undefined,
page: pagination.current,
page_size: pagination.pageSize
})
const data = res?.data
if (data) {
tableData.value = Array.isArray(data.list) ? data.list : Array.isArray(data) ? data : []
pagination.total = data.total || 0
} else {
tableData.value = []
pagination.total = 0
}
tableData.value = list
pagination.total = total
} catch (err) {
ElMessage.error('获取列表失败')
} finally {
@ -369,11 +361,7 @@ async function handleReject(row: any) {
await ElMessageBox.confirm('确认删除该条记录?', '提示', {
type: 'warning'
})
const formData = new FormData()
formData.append('id', String(row.id))
await request.post('/product_log/delete', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
await deleteProductLog(row.id)
ElMessage.success('已删除')
fetchData()
} catch {

View File

@ -221,9 +221,8 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, Refresh, View, Loading, CopyDocument } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import axios from 'axios'
import { copyToClipboard } from '@/utils/clipboard'
import { fetchShippingOrderList, fetchShippingOrderDetail, updateShippingOrderLogistics } from '@/api/shipping-order'
import { fetchShippingOrderList, fetchShippingOrderDetail, updateShippingOrderLogistics, fetchFastMailList, createOrderBatch, createBmOrderDaYin, submitCompanyOrder } from '@/api/shipping-order'
import { fetchWarehouseList } from '@/api/warehouse'
import { createPrintTask } from '@/api/print'
@ -434,13 +433,11 @@ export default defineComponent({
try {
//
const res = await axios.get('https://api.buzhiyushu.cn/zhishu/fastMail/listApi', {
params: { shopId: salesPersonId }
})
console.log('fastMail/listApi 响应:', res.data)
const res = await fetchFastMailList(salesPersonId)
console.log('fastMail/listApi 响应:', res)
// fastMailType=1
const data = res.data?.data
const data = res?.data
if (!Array.isArray(data) || data.length === 0) {
ElMessage.error('未获取到快递账号配置')
isProcessing.value = false
@ -458,33 +455,31 @@ export default defineComponent({
return
}
const formData = new FormData()
formData.append('type', fastMail.type)
formData.append('partnerId', fastMail.partnerId)
formData.append('secret', fastMail.secret)
formData.append('orderSn', currentItem.association_order_no)
formData.append('contact', currentItem.warehouse_contact_person)
formData.append('phoneNumber', currentItem.warehouse_contact_phone)
formData.append('province', currentItem.warehouse_province)
formData.append('city', currentItem.warehouse_city)
formData.append('area', currentItem.warehouse_district)
formData.append('town', currentItem.warehouse_address)
const createRes = await axios.post('https://print.buzhiyushu.cn/api/print/createOrderBatch', formData)
console.log('createOrderBatch 响应:', createRes.data)
const createRes = await createOrderBatch({
type: fastMail.type,
partnerId: fastMail.partnerId,
secret: fastMail.secret,
orderSn: currentItem.association_order_no,
contact: currentItem.warehouse_contact_person,
phoneNumber: currentItem.warehouse_contact_phone,
province: currentItem.warehouse_province,
city: currentItem.warehouse_city,
area: currentItem.warehouse_district,
town: currentItem.warehouse_address
})
console.log('createOrderBatch 响应:', createRes)
// PDF
const createData = await axios.get('https://print.buzhiyushu.cn/api/print/createBmOrderDaYin', {
params: {
mailno: createRes.data.data[0].mail_no || createRes.data.data[0].mailno,
partnerId: fastMail.partnerId,
secret: fastMail.secret
}
const createData = await createBmOrderDaYin({
mailno: createRes.data[0].mail_no || createRes.data[0].mailno,
partnerId: fastMail.partnerId,
secret: fastMail.secret
})
console.log('createBmOrderDaYin 响应:', createData.data)
console.log('createBmOrderDaYin 响应:', createData)
const printData = createRes.data.data[0]
printData.pdf_info = createData.data.pdfInfo
printData.itemList = createRes.data.erpGoodsOrderList[0].itemList;
const printData = createRes.data[0]
printData.pdf_info = createData.pdfInfo
printData.itemList = createRes.erpGoodsOrderList[0].itemList;
console.log('createBmOrderDaYin 响应:', JSON.stringify(printData))
// createPrintTask
@ -500,22 +495,20 @@ export default defineComponent({
shipping_order_id: currentItem.shipping_order_id,
sales_order_item_id: currentItem.sales_order_item_id,
logistics_company: fastMail.type,
logistics_no: createRes.data.data[0].mail_no || createRes.data.data[0].mailno
logistics_no: createRes.data[0].mail_no || createRes.data[0].mailno
})
}
console.log('正在回填快递单号并更新状态...')
const submitCompany = await axios.post('https://api.buzhiyushu.cn/zhishu/orderExternalGoods/submitCompanyOrder', {
const submitCompany = await submitCompanyOrder({
code: fastMail.type,
orderNo: createRes.data.data[0].mail_no || createRes.data.data[0].mailno,
erpOrderId: createRes.data.erpGoodsOrderList[0].id.toString()
}, {
headers: { 'Content-Type': 'application/json' }
orderNo: createRes.data[0].mail_no || createRes.data[0].mailno,
erpOrderId: createRes.erpGoodsOrderList[0].id.toString()
});
console.log('回填快递单号 响应:', submitCompany.data)
console.log('回填快递单号 响应:', submitCompany)
//
scanList.value[currentScanIndex.value].logistics_no = createRes.data.data[0].mail_no || createRes.data.data[0].mailno
scanList.value[currentScanIndex.value].logistics_no = createRes.data[0].mail_no || createRes.data[0].mailno
} catch (err) {
console.error('发货处理失败:', err)
ElMessage.error('发货处理失败,请重试')

View File

@ -1,7 +1,7 @@
<template>
<div class="wave-page">
<Car ref="carRef" :disabled="isPageLocked || hasActiveWave" :on-query="handleIsbnQuery" />
<Car ref="carRef" :disabled="isPageLocked" :on-query="handleIsbnQuery" />
<!-- 页面锁定遮罩层 -->
<div class="page-lock-overlay" v-if="isPageLocked"></div>
@ -175,12 +175,6 @@ const selectedCarCapacity = computed(() => {
return car.selectedCarCapacity
})
// camera.vue
const hasActiveWave = computed(() => {
const cam = cameraRef.value as { hasActiveWave?: boolean } | null
return cam?.hasActiveWave ?? false
})
// goodsInfo
const currentGoods = ref<GoodsInfoType | null>(null)

Binary file not shown.

View File

@ -174,8 +174,8 @@ module.exports = defineConfig({
historyApiFallback: true,
proxy: {
'/api': {
// target: 'http://192.168.101.213:9090',
target: 'https://psi.api.buzhiyushu.cn',
target: 'http://192.168.101.213:9090',
// target: 'https://psi.api.buzhiyushu.cn',
changeOrigin: true
},
'/api/print': {

454
使用文档.md Normal file
View File

@ -0,0 +1,454 @@
# 图书进销存系统 — 使用文档
> **系统名称**Cards图书进销存管理后台
> **适用对象**:系统管理员、运营人员、仓库人员
> **最后更新**2026-06-03
---
## 目录
1. [系统概述](#1-系统概述)
2. [登录与首页](#2-登录与首页)
3. [导航与布局](#3-导航与布局)
4. [代理管理(管理员)](#4-代理管理管理员)
5. [仓库与基础数据管理](#5-仓库与基础数据管理)
6. [波次管理(核心工作流)](#6-波次管理核心工作流)
7. [采购与销售](#7-采购与销售)
8. [出库与发货](#8-出库与发货)
9. [库存管理](#9-库存管理)
10. [商品与店铺管理](#10-商品与店铺管理)
11. [系统配置](#11-系统配置)
12. [常见操作指引](#12-常见操作指引)
13. [附:菜单速查表](#13-附菜单速查表)
---
## 1. 系统概述
图书进销存系统是一个面向图书行业的全流程管理平台,覆盖从图书**采购入库 → 库存管理 → 商品发布 → 销售出库 → 发货打单 → 盘库盘点**的完整业务链条。
系统主要服务两类角色:
| 角色 | 可访问的功能 |
|------|-------------|
| **管理员**role=255 | 全部功能,包括代理管理、异常书目审核、核价器配置等 |
| **普通用户/代理**role=128 | 仪表盘、波次管理、仓库管理、采购销售、库存等基础业务 |
---
## 2. 登录与首页
### 2.1 登录
1. 在浏览器中访问系统地址(开发环境:`http://localhost:5174`
2. 输入**用户名**和**密码**
3. 选择登录身份:
- **管理员登录** — 管理员账号,拥有全部权限
- **代理登录** — 代理账号,权限受限
4. 点击「登录」按钮进入系统
> **提示**:登录信息保存在浏览器中,关闭页面后重新打开无需重复登录。如忘记密码,请联系系统管理员重置。
### 2.2 首页(仪表盘)
登录后默认进入**仪表盘**页面,展示今日运营概况:
| 统计卡片 | 说明 |
|---------|------|
| **今日入库** | 今日采购入库数量(件) |
| **今日出库** | 今日出库单数量 |
| **今日销售** | 今日销售订单数量 |
页面下方两个列表:
- **今日最新商品** — 今日新录入的商品列表(名称、条码、价格、创建时间)
- **今日员工统计** — 各员工今日的入库/出库工作量统计
---
## 3. 导航与布局
### 3.1 界面结构
```
┌─────────────────────────────────────────────┐
│ 进销存系统 [折叠按钮] 用户名 ▼ 积分 │ ← 顶部栏
├──────────┬──────────────────────────────────┤
│ │ │
│ 仪表盘 │ │
│ 代理管理 │ 主内容区域 │
│ ├ 代理列表│ (router-view) │
│ ├ 添加代理│ │
│ └ 账户类型│ │
│ 供应商管理│ │
│ 仓库管理 │ │
│ ├ 仓库列表│ │
│ ├ 小车管理│ │
│ ├ 店铺管理│ │
│ ├ 打印机 │ │
│ └ PDA管理│ │
│ 波次管理 │ │
│ 采购/商品 │ │
│ 销售/出库 │ │
│ 库存/盘库 │ │
│ ... │ │
└──────────┴──────────────────────────────────┘
```
### 3.2 操作入口
- **左侧菜单** — 按功能模块分组,点击菜单项进入对应页面
- **折叠按钮** — 顶部栏左侧汉堡图标,可收起/展开左侧菜单(窄屏时有用)
- **用户菜单** — 顶部栏右侧显示当前用户名,点击可退出登录
- **积分显示** — 代理用户的当前积分显示在顶部栏右侧
### 3.3 嵌入模式
系统支持通过 URL 参数嵌入到其他系统:
```
http://host:port/path?token=xxx
```
嵌入模式下会自动隐藏左侧导航栏和顶部栏,仅显示内容区域。
---
## 4. 代理管理(管理员)
代理管理仅**管理员**role=255可见。
### 4.1 代理列表
- 展示所有代理账号的列表
- 支持搜索关键词(工号/姓名/账号)
- 可对代理进行**充值**(增加积分)或**扣减**(减少积分)
- 可**启用/禁用**代理账号
### 4.2 添加代理
表单字段:
- **姓名** — 代理真实姓名
- **密码** — 登录密码
- **手机号** — 联系电话
- **上级 ID** — 可选,指定上级代理
### 4.3 账户类型
管理员工账户类型配置,可设置不同类型的权限和有效期。
---
## 5. 仓库与基础数据管理
### 5.1 供应商管理
管理图书采购来源:
- **供应商列表** — 查看所有供应商(编号、名称、联系方式、状态)
- **新增/编辑/删除** — 维护供应商信息
- **搜索** — 按供应商名称或编码搜索
### 5.2 仓库管理
管理仓储资源:
- **仓库列表** — 查看所有仓库(编码、名称、类型、状态)
- **新增/编辑/删除** — 维护仓库信息
- **搜索** — 按仓库编码或名称搜索
### 5.3 库位管理
管理仓库内的存储位置:
- **库位列表** — 查看所有库位(编码、类型、容量、状态)
- **新增/编辑/删除** — 维护库位信息
- **批量生成** — 按规则一次性生成多个库位(例如 A-01-01 到 A-10-10
- **批量更新** — 批量修改库位属性
### 5.4 小车管理
管理仓库作业用的小推车/搬运车:
- **小车列表** — 查看所有小车(编号、名称)
- **新增/编辑/删除** — 维护小车信息
- **店铺关联** — 将小车与特定店铺关联,方便拣货作业
### 5.5 店铺管理
管理销售渠道:
- **店铺列表** — 查看所有店铺(名称、类型、授权状态)
- **新增/编辑/删除** — 维护店铺信息
- **授权管理** — 更新店铺授权状态(已授权/未授权/已过期)
- **支持平台**:拼多多、孔夫子旧书网、闲鱼等
### 5.6 打印机管理
管理系统中配置的打印机设备,用于打印快递面单等。
---
## 6. 波次管理(核心工作流)
波次管理是系统的核心操作模块,用于将图书信息录入到系统中并为后续的采购/出库做准备。
### 6.1 核价器配置
在使用波次功能前需要先配置核价器Pricer
- 输入核价器的 IP 地址和端口
- **测试连接** — 验证能否连接到核价器
- 配置价格参数(新书价格、占位降价、最低运费、最低价格等)
- 管理核价器的登录 Token
- **保存价格** — 将当前配置的价格参数保存到核价器
### 6.2 波次创建
波次创建是核心操作入口,使用**相机扫描 + 手动录入**结合的方式采集图书信息。
**操作步骤:**
1. **扫描条码** — 使用摄像头扫描图书条码ISBN自动查询书籍信息
2. **OCR 识别** — 拍摄书籍封面,自动识别书名、作者、出版社(如有需要)
3. **编辑信息** — 确认和修改书籍信息(书名、作者、出版社、定价、页数等)
- 支持实拍图片上传
- 支持套装书处理
4. **生成条码** — 为商品生成条码图片
5. **查询价格** — 从核价器获取商品的建议售价
6. **提交保存** — 将商品信息保存到系统
7. **创建波次** — 将一批商品归入同一波次,便于后续批量处理
### 6.3 波次任务列表
查看已创建的波次任务,支持按条件筛选和搜索。
---
## 7. 采购与销售
### 7.1 采购订单
管理图书采购全流程:
- **采购单列表** — 查看所有采购单(单号、供应商、仓库、状态、总金额)
- **筛选** — 按单号、状态、时间范围等条件筛选
- **创建采购单** — 选择供应商、仓库,添加商品明细
- **创建波次** — 采购单可创建波次进行入库操作
- **查看详情** — 查看采购单的商品明细和状态
### 7.2 商品管理
管理所有商品信息:
- **商品列表** — 查看所有商品(名称、条码、价格、图片、状态)
- **搜索/筛选** — 按关键字、店铺、状态搜索
- **新增/编辑/删除** — 维护商品信息
- **实拍图片** — 上传和管理商品实拍图片(支持本地路径和拼多多 URL
- **商品发布** — 将商品发布到各个店铺(详见"发布记录"
### 7.3 销售订单
管理图书销售流程:
- **销售单列表** — 查看所有销售订单(单号、客户、仓库、状态、金额)
- **筛选** — 按单号、状态、客户等条件筛选
- **创建销售单** — 选择客户、仓库,添加商品明细
- **确认订单** — 确认销售订单后可创建出库单
- **取消订单** — 可取消销售订单并填写原因
### 7.4 发布记录
查看商品发布到店铺的日志记录(当前为模拟数据):
- 按店铺分组展示每个商品的发布状态
- 状态包括:发布成功、发送到店铺失败、任务已创建未发送等
- **重试发布** — 发布失败的商品可点击重试
---
## 8. 出库与发货
### 8.1 出库管理
- **出库单列表** — 查看所有出库单(单号、仓库、状态、日期)
- **筛选** — 按单号、状态、仓库、日期范围筛选
- **创建出库单** — 从销售订单创建或手动创建
- **审核出库** — 审核通过后库存减少
- **导出** — 导出出库单详情
### 8.2 发货单
- **发货单列表** — 查看所有发货单(单号、物流信息、状态)
- **筛选** — 按单号、状态、仓库、客户等条件筛选
- **生成发货单** — 从出库单生成
- **填写物流信息** — 录入物流公司和快递单号
- **打印面单** — 对接快递打印接口,批量打印快递面单
- **回填快递单号** — 将快递单号回填到外部平台
---
## 9. 库存管理
### 9.1 库存查询
- **按商品汇总** — 按商品维度查看各仓库库存(总量/锁定量/可用量)
- **按仓库明细** — 按仓库+库位+批次查看库存明细
- **变动记录** — 查看库存变动日志(入库/出库/调整等)
### 9.2 盘库管理
用于定期盘点库存,确保系统库存与实际库存一致。
**操作流程:**
1. **创建盘库单**
- 选择盘点类型:**全盘**(盘点仓库全部商品)或 **抽盘**(指定部分商品)
- 选择仓库,填写备注
2. **开始盘点** — 盘库单状态变为"盘点中"
3. **录入盘点数据**
- 逐条录入:输入实盘数量
- 批量录入:一次性提交多条盘点结果
4. **完成盘点** — 系统更新实际库存
5. **调整库存** — 如果盘盈或盘亏,可手动调整库存(增加或减少)
6. **取消/删除** — 待盘点状态可取消或删除盘库单
---
## 10. 商品与店铺管理
### 10.1 商品管理详情
- **商品列表** 支持按商品名称、条码搜索
- 商品详情展示:名称、作者、出版社、定价(元)、条码、实拍图片等
- 支持**套装书**标识
### 10.2 异常书目审核(管理员)
当系统检测到书目信息异常(如书名/作者/价格与标准数据不一致),会在该模块展示:
- **异常记录列表** — 展示所有被标记为异常的记录
- **审核操作**
- **通过** — 确认新数据正确,更新到数据库
- **驳回** — 拒绝异常标记,保留原数据
- 可查看新旧数据对比(书名、作者、出版社、价格、页数等)
### 10.3 ISBN 悬停浮窗
在系统中任何显示 ISBN 的地方(如商品列表),将鼠标悬停在 ISBN 上会弹出浮窗,展示该书的详细信息:
- 封面图片
- 书名、作者、出版社
- 出版时间、装帧、定价
- 页数、字数
- 套装书标记
该功能有缓存机制,同一 ISBN 只查询一次。
---
## 11. 系统配置
### 11.1 核价器配置(管理员)
路径:波次管理 → 核价器配置
| 功能 | 操作说明 |
|------|---------|
| **连接设置** | 输入核价器的 IP 和端口,点击「测试连接」验证连通性 |
| **价格配置** | 设置新书价格、占位降价金额、最低运费、最低书价 |
| **价格保存** | 将配置的价格参数提交到核价器 |
| **Token 管理** | 查看和管理核价器的登录 Token新增/批量添加/删除) |
| **孔夫子登录** | 在核价器中登录孔夫子旧书网账号,用于自动上架 |
### 11.2 物流模板管理
- **物流模板列表** — 查看所有物流模板(名称、运费等)
- **新增/编辑/删除** — 维护物流模板
- 关联行政区划数据(省/市/区)
### 11.3 分拣设置
分拣规则配置(当前为预留功能,菜单默认隐藏)。
### 11.4 店铺设置
店铺通用配置(当前为预留功能,菜单默认隐藏)。
---
## 12. 常见操作指引
### 12.1 快速录入一本书
```
波次管理 → 进入波次页面
→ 扫描 ISBN 条码(自动查询书籍信息)
→ 确认书名、作者、出版社等信息
→ 输入定价、页数等补充信息
→ 可选:拍摄实拍图片 / OCR 识别封面
→ 点击保存 → 商品自动创建
```
### 12.2 处理一笔销售订单
```
销售订单 → 创建销售单
→ 选择客户、仓库 → 添加商品
→ 确认并提交 → 列表中确认订单
→ 进入出库管理 → 从销售单创建出库单
→ 审核出库 → 库存减少
→ 进入发货单 → 从出库单生成发货单
→ 填写物流信息 → 打印面单
```
### 12.3 进行月度盘点
```
盘库管理 → 创建盘库单
→ 选择仓库,盘点类型选"全盘"
→ 确认创建
→ 在列表中点击「开始盘点」
→ 逐条输入实盘数量
→ 全部完成后点击「完成盘点」
→ 如有差异,使用「库存调整」功能修正
```
### 12.4 查看商品库存
```
库存记录 → 库存查询
→ 选择商品 → 查看各仓库库存数量
→ 切换「明细」视图 → 查看具体库位和批次
→ 切换「变动记录」 → 查看该商品的出入库历史
```
### 12.5 退出登录
点击顶部栏右侧的用户名 → 选择「退出登录」→ 确认即可。
---
## 13. 附:菜单速查表
| 菜单 | 子菜单 | 页面路径 | 功能 |
|------|--------|---------|------|
| (首页) | — | `/dashboard` | 今日运营数据概览 |
| 代理管理 | 代理列表 | `/admin/employees` | 代理账号管理 |
| 代理管理 | 添加代理 | `/admin/employees/add` | 新增代理 |
| 代理管理 | 账户类型 | `/admin/employee-type` | 员工类型配置 |
| 供应商管理 | 供应商列表 | `/supplier` | 供应商管理 |
| 仓库管理 | 仓库列表 | `/warehouse` | 仓库管理 |
| 仓库管理 | 小车管理 | `/car` | 小车管理 |
| 仓库管理 | 店铺管理 | `/shop` | 店铺管理 |
| 仓库管理 | 打印机管理 | `/printer-manager` | 打印机管理 |
| 仓库管理 | PDA管理 | `/pdaManage` | PDA 管理 |
| 波次管理 | 核价器配置 | `/testUrl` | 核价器配置 |
| 波次管理 | 波次创建 | `/wave` | 相机扫码录书 |
| 波次管理 | 波次任务列表 | `/wave-task` | 波次任务查看 |
| 采购订单 | 采购单列表 | `/purchase-order` | 采购订单管理 |
| 商品管理 | 商品列表 | `/product` | 商品管理 |
| 商品管理 | 发布记录 | `/release-record` | 商品发布日志 |
| 销售订单 | 销售单列表 | `/sales-order` | 销售订单管理 |
| 出库管理 | 出库列表 | `/outbound` | 出库单管理 |
| 发货单 | 发货单列表 | `/shipping-order` | 发货单管理 |
| 库存记录 | 库存查询 | `/inventory` | 库存查询和变动记录 |
| 盘库管理 | 盘库单列表 | `/stock-check` | 盘库盘点管理 |
| — | 异常书目审核 | `/review-illegal-book` | 书目异常审核(管理员) |
| — | 物流模板管理 | `/logistics` | 物流模板管理 |
| — | 库位列表 | `/location` | 库位管理 |
---
*如在使用过程中遇到问题,请联系系统管理员或参考技术文档获取更多信息。*

489
技术文档.md Normal file
View File

@ -0,0 +1,489 @@
# 图书进销存系统 — 技术文档
> **项目名称**Cards图书进销存管理后台
> **版本**1.0.0
> **最后更新**2026-06-03
> **项目路径**`D:\project\cards_web`
---
## 目录
1. [项目概述](#1-项目概述)
2. [技术栈](#2-技术栈)
3. [项目结构](#3-项目结构)
4. [构建与部署](#4-构建与部署)
5. [路由与权限](#5-路由与权限)
6. [API 架构](#6-api-架构)
7. [状态管理](#7-状态管理)
8. [外部服务集成](#8-外部服务集成)
9. [核心业务流程](#9-核心业务流程)
10. [已知问题与维护](#10-已知问题与维护)
---
## 1. 项目概述
本项目是一个面向图书行业的**进销存管理后台系统**,覆盖图书采购入库、库存管理、商品发布、销售出库、发货打单、盘库盘点等全链路业务流程。系统支持多角色(管理员/代理权限控制并集成了核价器、快递打印、OCR 识别等外部服务。
### 核心业务范围
| 模块 | 说明 |
|------|------|
| **波次管理** | 相机扫码 → 条码识别 → 书籍信息编辑 → 波次创建/释放 |
| **订单管理** | 采购订单 → 销售订单 → 出库单 → 发货单,全链路可追溯 |
| **库存管理** | 库存查询(商品维度/仓库维度)、变动记录、盘库盘点 |
| **商品管理** | 商品 CRUD、实拍图片管理、多店铺发布/重试 |
| **店铺管理** | 多平台店铺(拼多多、孔夫子、闲鱼等)管理、授权状态 |
| **系统管理** | 代理账号、员工类型、核价器配置、物流模板、打印机管理 |
---
## 2. 技术栈
### 2.1 前端框架与依赖
| 依赖 | 版本 | 用途 |
|------|------|------|
| `vue` | ^3.2.8 | 前端框架Composition API + `<script setup>` |
| `vue-router` | ^4.1.6 | 前端路由History 模式) |
| `pinia` | ^2.1.7 | 状态管理 |
| `element-plus` | ^2.13.7 | UI 组件库(中文 localesmall 尺寸) |
| `@element-plus/icons-vue` | ^2.1.0 | 图标库 |
| `axios` | ^1.7.9 | HTTP 客户端 |
| `crypto-js` | ^4.2.0 | 签名算法MD5/SHA256 |
| `json-bigint` | ^1.0.0 | 大整数精度处理 |
| `@zxing/library` | ^0.21.3 | 条形码/二维码扫描 |
| `jsbarcode` | ^3.12.3 | 条码生成 |
| `qrcode` | ^1.5.4 | QR 码生成 |
### 2.2 工程化
| 工具 | 版本 | 用途 |
|------|------|------|
| `vite` | ^2.5.2 | 构建工具与开发服务器 |
| `typescript` | ^5.5.4 | 类型检查(项目以 JS 为主,部分 .ts 文件) |
| `sass` | ^1.99.0 | CSS 预处理器 |
### 2.3 环境变量(`.env`
| 变量 | 值 | 说明 |
|------|-----|------|
| `VITE_APP_API_KEY` | `psi` | API 签名密钥 |
| `VITE_APP_CLIENT_ID` | `psi` | 客户端 ID |
| `VITE_APP_API_SECRET` | `psi_api_sign_secret` | 签名密钥 |
---
## 3. 项目结构
```
src/
├── api/ # API 层:所有后端接口调用集中于此
│ ├── barcode.js # 条码生成
│ ├── book.js # 书籍信息查询/同步
│ ├── car.js # 小车管理
│ ├── config.js # 核价器配置(直连外部 IP:Port
│ ├── dashboard.js # 仪表盘统计
│ ├── district.js # 行政区划(省/市/区)及运费模板
│ ├── employee.js # 员工/代理管理
│ ├── employeeType.js # 员工类型管理
│ ├── goodsInfo.js # 商品图像信息
│ └── submIllegalBook.js # 提交异常书目
│ ├── inventory.js # 库存记录
│ ├── location.js # 库位管理
│ ├── login.js # 登录
│ ├── logistics.js # 物流模板
│ ├── outbound.js # 出库管理
│ ├── print.js # 打印服务(导出 Lodop 方法)
│ ├── product.js # 商品管理
│ ├── purchase-order.js # 采购订单
│ ├── releaseRecord.js # 发布记录(当前为 Mock 数据)
│ ├── reviewIllegalBook.js # 异常书目审核
│ ├── sales-order.js # 销售订单
│ ├── shipping-order.js # 发货单(含外部快递接口)
│ ├── shop.js # 店铺管理(含外部 API + 内部 API
│ ├── stock-check.js # 盘库管理
│ ├── supplier.js # 供应商管理
│ ├── warehouse.js # 仓库管理
│ └── wave-task.js # 波次任务
├── components/ # 公共组件
│ ├── AdminLayout.vue # 主布局(侧边栏+顶部栏+内容区)
│ ├── goodsPop/index.vue # ISBN 悬停浮窗(书品信息预览)
│ ├── inventory/ # 库存子组件byGoods, byLocation
│ ├── product/ # 商品子组件byGoods, byLocation
│ ├── wave/ # 波次子组件camera, car, bookInfo 等)
│ └── wareHouse/ # 仓库子组件WarehouseList, LocationManager
├── views/ # 页面级组件
│ ├── login/Login.vue # 登录页
│ ├── admin/ # 管理员页面Dashboard, EmployeeList, EmployeeAdd, employeeType
│ ├── wave/Wave.vue # 波次管理主页面
│ ├── warehouse/ # 仓库列表
│ ├── supplier/Supplier.vue # 供应商列表
│ ├── product/Product.vue # 商品列表
│ ├── purchase-order/ # 采购订单
│ ├── sales-order/ # 销售订单
│ ├── shipping-order/ # 发货单
│ ├── outbound/Outbound.vue # 出库管理
│ ├── inventory/Inventory.vue # 库存记录
│ ├── stock-check/ # 盘库管理
│ ├── shop/Shop.vue # 店铺管理
│ ├── car/Car.vue # 小车管理
│ ├── config/config.vue # 核价器配置
│ ├── logistics/index.vue # 物流模板
│ ├── releaseRecord/ # 发布记录
│ ├── reviewIllegalBook/ # 异常书目审核
│ ├── printer-manager/ # 打印机管理
│ ├── shop-settings/ # 店铺设置
│ ├── sorting-settings/ # 分拣设置
│ ├── location/Location.vue # 库位列表
│ ├── scanner/Scanner.vue # 扫码页
│ ├── pdaManage/ # PDA 管理
│ └── wave-task/WaveTask.vue # 波次任务列表
├── router/index.js # 路由配置History 模式 + 路由守卫)
├── store/ # 状态管理
│ ├── index.js # 旧版 storereactive 方式,逐步废弃)
│ └── user.js # Pinia 用户 store
├── utils/ # 工具函数
│ ├── request.js # Axios 封装拦截器、签名、Mock
│ ├── auth.js # Token/UserInfo 的 localStorage 读写
│ ├── sign.js # 签名工具MD5/SHA256参数排序
│ ├── mock.js # Mock 数据
│ ├── clipboard.js # 剪贴板工具
│ ├── daYin/print.ts # Lodop 打印服务
│ ├── daYinPdd/index.ts # 拼多多打印
│ ├── pddUpload.ts # 拼多多图片上传
│ └── ServiceManager.js # 服务管理器
├── App.vue # 根组件
└── main.js # 入口文件(注册 Element Plus / Pinia / Router
```
---
## 4. 构建与部署
### 4.1 开发服务器
```bash
npm run dev
# → http://localhost:5174 host: 0.0.0.0
```
### 4.2 生产构建
```bash
npm run build
# → 输出到 dist/
```
### 4.3 Vite 配置要点
`vite.config.js` 中:
| 配置项 | 值 | 说明 |
|--------|-----|------|
| **代理** | `/api``http://192.168.101.213:9090` | 主业务 API |
| **代理** | `/api/print``http://192.168.101.127:8075` | 打印服务 |
| **别名** | `@``./src` | 模块路径别名 |
| **端口** | `5174` | 开发服务器端口 |
| **自定义中间件** | `/kongfz-login` | 孔夫子登录代理(避免跨域) |
---
## 5. 路由与权限
### 5.1 路由模式
`createWebHistory()` — History 模式URL 格式为 `http://host:port/path`
### 5.2 路由表
| 路径 | 组件 | 标题 | 权限 |
|------|------|------|------|
| `/login` | Login.vue | 登录 | 无(已登录自动跳转) |
| `/dashboard` | Dashboard.vue | 仪表盘 | 需认证 |
| `/admin/employees` | EmployeeList.vue | 代理管理 | 管理员 |
| `/admin/employees/add` | EmployeeAdd.vue | 添加代理 | 管理员 |
| `/admin/employee-type` | employeeType.vue | 员工类型管理 | 管理员 |
| `/wave` | Wave.vue | 波次管理 | 需认证 |
| `/warehouse` | WareHouse.vue | 仓库列表 | 需认证 |
| `/supplier` | Supplier.vue | 供应商管理 | 需认证 |
| `/product` | Product.vue | 商品管理 | 需认证 |
| `/purchase-order` | PurchaseOrder.vue | 采购订单 | 需认证 |
| `/sales-order` | SalesOrder.vue | 销售订单 | 需认证 |
| `/outbound` | Outbound.vue | 出库管理 | 需认证 |
| `/shipping-order` | ShippingOrder.vue | 发货单 | 需认证 |
| `/inventory` | Inventory.vue | 库存记录 | 需认证 |
| `/stock-check` | StockCheck.vue | 盘库管理 | 需认证 |
| `/release-record` | releaseRecord.vue | 发布记录 | 需认证 |
| `/shop` | Shop.vue | 店铺管理 | 需认证 |
| `/car` | Car.vue | 小车管理 | 需认证 |
| `/location` | Location.vue | 库位列表 | 需认证 |
| `/review-illegal-book` | ReviewIllegalBook.vue | 异常书目审核 | 管理员 |
| `/testUrl` | config.vue | 核价器配置 | 管理员 |
| `/printer-manager` | PrinterManager.vue | 打印机管理 | 需认证 |
| `/logistics` | logistics/index.vue | 物流模板管理 | 需认证 |
| ... | 其他 | 隐藏菜单路由 | 需认证 |
### 5.3 路由守卫逻辑
1. **URL 参数注入**:检测 `?token=xxx` 参数,自动写入 localStorage 并解析 JWT用于跨系统嵌入
2. **登录守卫**:未登录用户自动重定向到 `/login`
3. **管理员守卫**`requiresAdmin: true` 的路由仅 `role=255` 可访问,否则重定向到 `/dashboard`
### 5.4 角色权限
| role 值 | 身份 | 说明 |
|---------|------|------|
| `255` | 管理员 | 全部功能可用 |
| `128` | 代理 | 仪表盘、波次、仓库、商品等基础功能 |
---
## 6. API 架构
### 6.1 网络拓扑
```
浏览器 ──→ Vite Dev Server (:5174)
├── /api/* ──→ Proxy ──→ http://192.168.101.213:9090 (主业务 API
├── /api/print/* ──→ Proxy ──→ http://192.168.101.127:8075 (打印服务)
├── /kongfz-login ──→ Node.js 中间件 ──→ login.kongfz.com (孔夫子登录)
└── axios 直连 ──→ 核价器 http://{ip}:{port} (用户配置)
──→ https://api.buzhiyushu.cn (店铺列表/快递回填)
──→ https://print.buzhiyushu.cn (快递面单打印)
```
### 6.2 请求封装(`src/utils/request.js`
核心流程:
```
请求发起
├── 请求拦截器
│ ├── 注入 Bearer Token从 localStorage.admin_token 读取)
│ ├── 生成签名参数params/body → MD5 排序签名,可跳过)
│ └── 对象自动转 FormDataPOST/PUT/DELETE
└── 响应拦截器
├── code=200 → 返回 response.data
├── code≠200 → 统一 ElMessage 错误提示
├── HTTP 401 → 清除 token 并跳转登录页
├── HTTP 403/404/500 → 对应错误提示
└── 网络错误 → "检查网络连接"
```
**签名机制**`src/utils/sign.js`
- 参数平铺排序(支持嵌套对象和数组)
- `app_secret + 参数字符串 + app_secret` 进行 MD5 签名
- 自动注入 `app_key`、`client_id`、`timestamp`、`sign_method` 等系统参数
- 通过请求头 `X-Need-Sign: false` 可跳过签名
**大整数精度**
- 使用 `json-bigint` 替换默认的 JSON.parse防止后端返回的 19 位大整数丢失精度
### 6.3 API 模块总览
| 文件 | 接口前缀 | 风格 | 说明 |
|------|---------|------|------|
| `login.js` | `/login/{role}` | request | 管理员/代理登录 |
| `dashboard.js` | `/dashboard/statist` | request | 仪表盘统计 |
| `barcode.js` | `/barcode/generate` | request+FormData | 条码生成 |
| `book.js` | `/getBookInfo`, `/syncBook` | request | 书籍查询/同步 |
| `car.js` | `/car/*` | request | 小车 CRUD |
| `config.js` | 外部 URL `http://{ip}:{port}` | axios 直连 | 核价器(价格查询/Token管理 |
| `district.js` | `/district/*` | request | 省市区数据、运费模板 |
| `employee.js` | `/admin/employee/*` | request | 代理增删改查、积分操作 |
| `employeeType.js` | `/admin/user-type/*` | request | 员工类型管理 |
| `goodsInfo.js` | `/product_log/save` | request+FormData | 提交书目异常 |
| `inventory.js` | `/inventory/*` | request | 库存汇总/明细/变动记录 |
| `location.js` | `/location/*` | request | 库位 CRUD、批量生成 |
| `logistics.js` | `/logistics/*` | request | 物流模板 CRUD |
| `outbound.js` | `/outbound-order/*` | request | 出库单 CRUD、审核、导出 |
| `print.js` | — | 导出 ts 方法 | 打印服务Lodop |
| `product.js` | `/product/*`, `/getSuitBook`, `/ocr` | request | 商品 CRUD、图片管理、OCR |
| `purchase-order.js` | `/purchase-order/*`, `/wave/release` | request | 采购单 CRUD、波次释放 |
| `releaseRecord.js` | — | **Mock** | 发布记录(纯前端模拟数据) |
| `reviewIllegalBook.js` | `/product_log/*` | request | 异常书目查询/删除 |
| `sales-order.js` | `/sales-order/*` | request | 销售订单 CRUD、确认、取消 |
| `shipping-order.js` | `/shipping-order/*` | request | 发货单 CRUD + 外部直连 |
| `shop.js` | `/shop/*` + 外部 URL | request+axios | 店铺 CRUD + 外部列表查询 |
| `stock-check.js` | `/inventory/stock-check/*` | request | 盘库单 CRUD、盘点提交 |
| `submIllegalBook.js` | `/product_log/audit` | request+FormData | 异常书目审核 |
| `supplier.js` | `/supplier/*` | request | 供应商 CRUD |
| `warehouse.js` | `/warehouse/*` | request | 仓库 CRUD |
| `wave-task.js` | `/wave/*` | request | 波次任务/列表 |
### 6.4 列表接口标准化
大部分 API 模块实现了 `normalizeListResponse()` 工具函数,将后端可能返回的各种列表格式统一为 `{ list: Array, total: number }`
```javascript
const normalizeListResponse = (payload) => {
const data = payload?.data
if (!data) return { list: [], total: 0 }
if (Array.isArray(data)) return { list: data, total: data.length }
return {
list: Array.isArray(data.list) ? data.list : [],
total: typeof data.total === 'number' ? data.total : (data.list?.length || 0)
}
}
```
### 6.5 Mock 模式
`src/utils/request.js``USE_MOCK = false`。启用后支持模拟数据(商品、卡密、订单、代理等),用于离线开发调试。
---
## 7. 状态管理
### 7.1 Pinia Store`src/store/user.js`
| 状态/方法 | 类型 | 说明 |
|-----------|------|------|
| `adminUserInfo` | `ref` | 管理员用户信息(从 localStorage 初始化) |
| `userInfo` | `computed` | adminUserInfo 的别名 |
| `isAdmin` | `computed` | `role === 255` |
| `points` | `computed` | 当前积分 |
| `setUserInfoAction(info)` | 方法 | 更新用户信息(同步到 localStorage |
| `updatePoints(newPoints)` | 方法 | 更新积分 |
| `clearAdminUserInfo()` | 方法 | 清除用户信息(登出) |
### 7.2 旧版 Store`src/store/index.js`
基于 Vue `reactive` 的简单 store未使用 Pinia逐步废弃中。
### 7.3 认证状态
认证信息存储于 `localStorage`,通过 `src/utils/auth.js` 统一管理:
| Key | 内容 | 用途 |
|-----|------|------|
| `admin_token` | JWT Token | 管理员 Bearer Token |
| `admin_userInfo` | JSON 用户信息 | 管理员信息(含 role、about_id 等) |
| `embed_mode` | `"true"` | 嵌入模式标志(隐藏导航栏) |
---
## 8. 外部服务集成
### 8.1 核价器Pricer
- **方式**:通过 `axios` 直连(不经过项目主 API连接用户配置的 IP:Port
- **功能**:查询商品价格、设置价格、管理登录 Token
- **文件**`src/api/config.js`,全部为 axios 直连请求
### 8.2 快递打印
- **方式**:通过 `axios` 直连
- **接口**
- `api.buzhiyushu.cn` — 快递账号列表、回填快递单号
- `print.buzhiyushu.cn` — 创建快递面单、获取打印面单
- **特点**:使用独立的 `axios` 实例(不经过 request 拦截器签名)
- **文件**`src/api/shipping-order.js`
### 8.3 店铺列表
- 内部接口(经过代理)和外部接口(`api.buzhiyushu.cn` 直连)两条路径
- 外部接口使用独立 axios 实例 + JSONbig 处理大整数
- **文件**`src/api/shop.js`
### 8.4 孔夫子旧书网登录
- 通过 Vite 开发服务器自定义中间件 `/kongfz-login` 实现
- 模拟浏览器登录流程POST 表单 → 处理重定向 → 提取 PHPSESSID → 获取用户信息
- 绕过浏览器跨域限制
### 8.5 OCR 识别
- 图片上传到 `/ocr` 接口进行文字识别
- 跳过签名(`X-Need-Sign: false`),保持 FormData 中 Blob 数据完整性
- **文件**`src/api/product.js` 中的 `ocrImage()`
### 8.6 Lodop 打印
- `src/utils/daYin/print.ts` — Clodop/Lodop 打印服务封装
- `src/utils/daYinPdd/index.ts` — 拼多多打印服务
---
## 9. 核心业务流程
### 9.1 波次管理流程
```
相机扫码 (camera.vue)
├── 扫描条码 → 查询书籍信息 (getBookInfo)
├── OCR 识别 → 提取书名/作者/出版社 (ocrImage)
├── 生成条码图片 (generateBarcode)
├── 编辑书籍信息 → 同步到系统 (syncBook)
└── 添加商品 → 创建波次 (createWaveOutbound)
└── 释放波次 → 生成采购/出库任务 (releaseWave)
```
### 9.2 采购到出库流程
```
创建采购订单 (createPurchaseOrder)
└── 创建波次 → 释放波次 → 生成波次任务
└── 采购入库 → 库存增加
└── 创建销售订单 (createSalesOrder)
└── 确认销售订单 → 创建出库单 (createOutbound)
└── 审核出库单 → 生成发货单 (createShippingOrder)
└── 填写物流信息 → 回填快递单号 (submitCompanyOrder)
```
### 9.3 盘库流程
```
创建盘库单 (createStockCheck) — 全盘/抽盘
└── 开始盘点 (startStockCheck) → 状态变为"盘点中"
└── 逐条/批量提交盘点结果 (submitCheckItem / batchSubmitCheckItems)
└── 完成盘点 (completeStockCheck) → 状态变为"已完成"
```
### 9.4 店铺商品发布
```
商品录入 → 选择店铺 → 发布商品
└── 查看发布记录(当前为 Mock 数据)
└── 失败重试 (retryRelease)
```
---
## 10. 已知问题与维护
### 10.1 遗留问题
| # | 问题 | 说明 | 建议 |
|---|------|------|------|
| 1 | Vite 2 版本较旧 | `^2.5.2` 有已知安全更新 | 建议升级到 Vite 4+ |
| 2 | TypeScript 使用不一致 | `tsconfig.json` 存在但项目以 JS 为主 | 统一规范或移除 TS |
| 3 | 发布记录为 Mock 数据 | `releaseRecord.js` 无后端接口 | 待后端接口对接 |
| 4 | 旧版 Store 冗余 | `store/index.js` 与 Pinia 并存 | 可迁移清理 |
| 5 | 备份文件残留 | `.bak` 后缀文件 | 确认后可删除 |
### 10.2 开发规范
- **API 层**:所有后端接口调用集中在 `src/api/`,视图层不直接调用 `request`
- **列表接口**:统一使用 `normalizeListResponse()` 标准化返回格式
- **接口文档**:优先用 JSDoc 注释描述参数和返回值
- **命名**API 函数使用 `fetchXxx`(查询)、`createXxx`(新增)、`updateXxx`(更新)、`deleteXxx`(删除)
### 10.3 添加新模块步骤
1. 在 `src/api/` 下新建 `<module>.js`,定义所有接口函数
2. 在 `src/views/` 下新建页面目录及 `.vue` 组件
3. 在 `src/router/index.js``children` 中添加路由
4. 确保 `meta` 中正确设置 `requiresAuth` / `requiresAdmin`
5. 在 `AdminLayout.vue` 中添加菜单项
---
*本文档随项目维护持续更新*