移动端功能基本完成

This commit is contained in:
2026-04-24 20:52:31 +08:00
parent 8dce0346a7
commit fe17c9a361
31 changed files with 8051 additions and 117 deletions
+142 -11
View File
@@ -1,31 +1,162 @@
/**
* API 请求封装
* 基于 uni.request,统一处理 Cookie 认证和错误
*/
import { useConfigStore } from '../stores/config'
// Storage Keys
const STORAGE_KEY_COOKIE_SESSION = 'userCookieSession'
// 错误码定义
const ERR_COOKIE_EXPIRED = 'userCookieError'
/**
* 获取 API 基础地址
*/
function getBaseUrl() {
const configStore = useConfigStore()
return configStore.getApiBaseUrl()
}
/**
* 获取保存的 Cookie 值
*/
function getCookie() {
try {
// 优先从 storage 读取(避免 Pinia 初始化时序问题)
const cookieStr = uni.getStorageSync(STORAGE_KEY_COOKIE_SESSION)
if (cookieStr) {
const cookie = JSON.parse(cookieStr)
return cookie?.Value ?? ''
}
} catch {
// ignore
}
return ''
}
/**
* 请求封装 - 统一错误处理
*/
function request(options) {
return new Promise((resolve, reject) => {
const baseUrl = getBaseUrl()
if (!baseUrl) {
reject({ errCode: -1, message: 'API地址未配置' })
return
}
// GET 请求不注入 cookiePOST 请求注入 cookie
let data = options.data || {}
if (options.method === 'POST') {
const cookie = getCookie()
// 后端期望格式: { data: {...业务数据}, userCookieValue: "xxx" }
data = {
data: data, // 业务数据包装在 data 字段
userCookieValue: cookie || undefined
}
}
uni.request({
url: baseUrl + options.path,
method: options.method || 'GET',
data,
header: {
'Content-Type': 'application/json'
},
timeout: options.timeout || 10000,
success: (res) => {
const data = res.data
// 检查 Cookie 过期
if (data?.err_code === ERR_COOKIE_EXPIRED || data?.err_code === -44) {
// 清除存储的 Cookie
uni.removeStorageSync(STORAGE_KEY_COOKIE_SESSION)
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
reject({ errCode: -44, message: '登录已过期' })
return
}
if (res.statusCode === 200) {
resolve({
errCode: data?.err_code === 0 ? 0 : -1,
data: data?.return || null,
raw: data
})
} else {
uni.showToast({ title: '服务异常', icon: 'none' })
reject({ errCode: -1, message: '服务异常' })
}
},
fail: (err) => {
uni.showToast({ title: '网络错误', icon: 'none' })
reject({ errCode: -2, message: '网络错误', detail: err })
}
})
})
}
/**
* API 接口汇总
* 按模块分组管理接口
*/
// 用户相关
export const api = {
/**
* GET 请求(一般不需要认证)
* GET 请求(不需要认证)
*/
get(path) {
get(path, data = {}) {
return request({
path,
method: 'GET',
data
})
},
/**
* POST JSON
* POST JSON 请求(需要认证)
*/
post(path, data = {},callback) {
console.log("post")
post(path, data = {}) {
return request({
path,
method: 'POST',
data // 业务数据,会被包装成 { data, userCookieValue }
})
},
/**
* POST FormData(文件上传)
*/
upload(path, file) {
upload(path, fileData) {
return new Promise((resolve, reject) => {
const baseUrl = getBaseUrl()
const cookie = getCookie()
uni.uploadFile({
url: baseUrl + path,
filePath: fileData.uri,
name: fileData.name || 'file',
formData: {
cookie: cookie || ''
},
success: (res) => {
try {
const data = JSON.parse(res.data)
resolve({
errCode: data?.err_code === 0 ? 0 : -1,
data: data?.return || null,
raw: data
})
} catch {
reject({ errCode: -1, message: '解析响应失败' })
}
},
fail: (err) => {
uni.showToast({ title: '上传失败', icon: 'none' })
reject({ errCode: -2, message: '上传失败', detail: err })
}
})
})
}
}
export default api
+33
View File
@@ -0,0 +1,33 @@
/**
* 采购订单 API
*/
import api from './index.js'
export const purchaseApi = {
// 获取订单列表
getOrders(data = {}) {
return api.post('/purchase/getorders', data)
},
// 获取订单详情
getOrder(data = {}) {
return api.post('/purchase/getorder', data)
},
// 获取订单数量统计
getOrderCount() {
return api.post('/purchase/getordercount', {})
},
// 更新订单状态
updateOrderStatus(data = {}) {
return api.post('/purchase/updatestatus', data)
},
// 新增订单
addOrder(data = {}) {
return api.post('/purchase/addorder', data)
}
}
export default purchaseApi
+28
View File
@@ -0,0 +1,28 @@
/**
* 日程管理 API
*/
import api from './index.js'
export const scheduleApi = {
// 获取日程列表
getEvents(data = {}) {
return api.post('/schedule/getevents', data)
},
// 新增日程
addEvent(data = {}) {
return api.post('/schedule/addevent', data)
},
// 编辑日程
editEvent(data = {}) {
return api.post('/schedule/editevent', data)
},
// 删除日程
deleteEvent(data = {}) {
return api.post('/schedule/deleevent', data)
}
}
export default scheduleApi
+81 -4
View File
@@ -1,9 +1,86 @@
import api from ".";
/**
* 用户相关 API
*/
import api from './index'
export const userApi = {
login(username, password, remember) {
return api.post('/users/login', { username, password, remember })
/**
* 用户登录
* @param {string} username - 用户名
* @param {string} password - 密码
* @param {boolean} remember - 是否记住登录
* @returns {Promise<{errCode, data: {cookie}}>}
*/
login(username, password, remember = true) {
return api.post('/users/login', {
username,
password,
remember
})
},
/**
* 用户注册
* @param {string} username - 用户名
* @param {string} email - 邮箱
* @param {string} password - 密码
*/
register(username, email, password) {
return api.post('/users/register', {
username,
useremail: email,
userpass: password
})
},
/**
* 获取当前用户信息
* @returns {Promise<{errCode, data: {user, userInfo}}>}
*/
getUserInfo() {
return api.post('/users/getinfo', {})
},
/**
* 修改密码
* @param {string} oldPass - 旧密码
* @param {string} newPass - 新密码
*/
changePassword(oldPass, newPass) {
return api.post('/users/changePassword', {
oldpass: oldPass,
newpass: newPass
})
},
/**
* 修改邮箱
* @param {string} newEmail - 新邮箱
*/
changeEmail(newEmail) {
return api.post('/users/changeEmail', {
newemail: newEmail
})
},
/**
* 更新用户信息
* @param {Object} data - 用户信息 { username, remark, birthday }
*/
updateInfo(data) {
return api.post('/users/updateInfo', data)
},
/**
* 上传头像
* @param {string} fileUri - 文件本地路径
*/
updateAvatar(fileUri) {
return api.upload('/users/updateAvatar', {
name: 'file',
uri: fileUri
})
}
}
export default userApi
+13
View File
@@ -0,0 +1,13 @@
/**
* 用户信息 API
*/
import api from './index.js'
export const usersApi = {
// 通过用户ID获取用户信息(GET 请求)
getUserInfoFromUserID(userID) {
return api.get('/users/getuserinfo/' + userID)
}
}
export default usersApi
+66
View File
@@ -0,0 +1,66 @@
import api from './index.js'
export const warehouseApi = {
// 获取容器列表
listContainer(params = {}) {
return api.post('/warehouse/list_container', params)
},
// 获取容器详情
getContainer(id) {
return api.post('/warehouse/get_container', { id })
},
// 新增容器
addContainer(data) {
return api.post('/warehouse/add_container', data)
},
// 更新容器
updateContainer(data) {
return api.post('/warehouse/update_container', data)
},
// 删除容器
deleteContainer(id) {
return api.post('/warehouse/delete_container', { id })
},
// 获取物品列表
listItem(params = {}) {
return api.post('/warehouse/list_item', params)
},
// 获取物品详情
getItem(id) {
return api.post('/warehouse/get_item', { id })
},
// 新增物品
addItem(data) {
return api.post('/warehouse/add_item', data)
},
// 更新物品
updateItem(data) {
return api.post('/warehouse/update_item', data)
},
// 删除物品
deleteItem(id) {
return api.post('/warehouse/delete_item', { id })
},
// 移动物品
moveItem(id, containerId) {
return api.post('/warehouse/move_item', {
item_id: id,
new_container: containerId
})
},
// 获取仓库统计
getStats() {
return api.post('/warehouse/get_stats', {})
}
}
+43
View File
@@ -0,0 +1,43 @@
import api from './index.js'
export const workOrderApi = {
// 获取工单列表
list(params = {}) {
return api.post('/work_order/list', params)
},
// 获取工单详情
get(id) {
return api.post('/work_order/get', { id })
},
// 获取工单统计
getCount() {
return api.post('/work_order/count', {})
},
// 新增工单
add(data) {
return api.post('/work_order/add', data)
},
// 更新工单
update(data) {
return api.post('/work_order/update', data)
},
// 删除工单
delete(id) {
return api.post('/work_order/delete', { id })
},
// 提交进度
commit(data) {
return api.post('/work_order/commit', data)
},
// 搜索采购订单
searchPurchaseOrders(query, limit = 10) {
return api.post('/work_order/search_purchase_orders', { search: query, limit })
}
}
+6 -1
View File
@@ -68,5 +68,10 @@
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
"vueVersion" : "3",
"h5" : {
"devServer" : {
"port" : 5174
}
}
}
+72 -6
View File
@@ -13,9 +13,63 @@
}
},
{
"path": "pages/message/message",
"path": "pages/order/order-detail",
"style": {
"navigationBarTitleText": "消息"
"navigationBarTitleText": "订单详情"
}
},
{
"path": "pages/order/order-add",
"style": {
"navigationBarTitleText": "新增订单"
}
},
{
"path": "pages/workorder/workorder",
"style": {
"navigationBarTitleText": "工单"
}
},
{
"path": "pages/workorder/add-workorder",
"style": {
"navigationBarTitleText": "新建工单"
}
},
{
"path": "pages/workorder/show-workorder",
"style": {
"navigationBarTitleText": "工单详情"
}
},
{
"path": "pages/workorder/edit-workorder",
"style": {
"navigationBarTitleText": "编辑工单"
}
},
{
"path": "pages/warehouse/warehouse",
"style": {
"navigationBarTitleText": "仓库"
}
},
{
"path": "pages/warehouse/item-detail",
"style": {
"navigationBarTitleText": "物品详情"
}
},
{
"path": "pages/warehouse/add-item",
"style": {
"navigationBarTitleText": "新增物品"
}
},
{
"path": "pages/warehouse/item-edit",
"style": {
"navigationBarTitleText": "编辑物品"
}
},
{
@@ -35,6 +89,12 @@
"style": {
"navigationBarTitleText": "设置"
}
},
{
"path": "pages/message/message",
"style": {
"navigationBarTitleText": "消息"
}
}
],
"globalStyle": {
@@ -65,10 +125,16 @@
"text": "订单"
},
{
"pagePath": "pages/message/message",
"iconPath": "static/tabbar/message.png",
"selectedIconPath": "static/tabbar/message-active.png",
"text": "消息"
"pagePath": "pages/workorder/workorder",
"iconPath": "static/tabbar/workorder.png",
"selectedIconPath": "static/tabbar/workorder-active.png",
"text": "工单"
},
{
"pagePath": "pages/warehouse/warehouse",
"iconPath": "static/tabbar/warehouse.png",
"selectedIconPath": "static/tabbar/warehouse-active.png",
"text": "仓库"
},
{
"pagePath": "pages/user/user",
+398 -7
View File
@@ -1,23 +1,414 @@
<template>
<view class="container">
<text class="placeholder">主页</text>
<!-- 欢迎区域 -->
<view class="welcome-section">
<text class="welcome-title">{{ welcomeText }}</text>
<text class="welcome-date">{{ todayDisplay }}</text>
</view>
<!-- 今日日程 -->
<view class="card">
<view class="card-header">
<text class="card-title">📅 今日日程</text>
<text class="card-count" v-if="todaySchedules.length > 0">{{ todaySchedules.length }}</text>
</view>
<!-- 加载状态 -->
<view v-if="loadingSchedules" class="loading">
<text>加载中...</text>
</view>
<!-- 日程列表 -->
<view v-else-if="todaySchedules.length > 0" class="schedule-list">
<view
v-for="schedule in todaySchedules"
:key="schedule.ID"
class="schedule-item"
>
<view class="schedule-date" :style="{ backgroundColor: schedule.BgColor || '#007AFF' }">
<text>{{ formatScheduleDate(schedule) }}</text>
</view>
<view class="schedule-content">
<text class="schedule-title">{{ schedule.Title }}</text>
<text class="schedule-user">创建人: {{ getCreatorName(schedule.UserID) }}</text>
</view>
</view>
</view>
<!-- 无日程 -->
<view v-else class="empty">
<text class="empty-text">今日暂无日程</text>
</view>
</view>
<!-- 订单统计 -->
<view class="card">
<view class="card-header">
<text class="card-title">📦 待处理订单</text>
</view>
<!-- 加载状态 -->
<view v-if="loadingOrders" class="loading">
<text>加载中...</text>
</view>
<!-- 订单数量 -->
<view v-else class="order-stats">
<view class="stat-item" @click="switchToTab('/pages/order/order')">
<text class="stat-num" :class="{ 'stat-warning': pendingCount > 0 }">{{ pendingCount || '—' }}</text>
<text class="stat-label">待处理</text>
</view>
<view class="stat-item" @click="switchToTab('/pages/order/order')">
<text class="stat-num">{{ arrivedCount || '—' }}</text>
<text class="stat-label">已到达</text>
</view>
<view class="stat-item" @click="switchToTab('/pages/order/order')">
<text class="stat-num">{{ receivedCount || '—' }}</text>
<text class="stat-label">已收件</text>
</view>
</view>
</view>
<!-- 功能入口 -->
<view class="card">
<view class="card-header">
<text class="card-title">🚀 快捷入口</text>
</view>
<view class="quick-links">
<view class="quick-item" @click="switchToTab('/pages/order/order')">
<text class="quick-icon">📦</text>
<text class="quick-text">订单管理</text>
</view>
<view class="quick-item" @click="switchToTab('/pages/warehouse/warehouse')">
<text class="quick-icon">🏭</text>
<text class="quick-text">仓库管理</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { onShow, onHide } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { useUserStore } from '../../stores/user'
import { scheduleApi } from '../../api/schedule'
import { purchaseApi } from '../../api/purchase'
import { fetchUserInfo, getUsername } from '../../stores/users'
const userStore = useUserStore()
// 今日日期
const today = new Date()
const todayStr = today.toISOString().split('T')[0]
// 欢迎语
const welcomeText = computed(() => {
if (userStore.isLoggedIn) {
return `${userStore.username || '用户'},您好!`
}
return '欢迎使用 OPS 系统'
})
// 今日日期显示
const todayDisplay = computed(() => {
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${today.getMonth() + 1}${today.getDate()}${weekdays[today.getDay()]}`
})
// 日程数据
const todaySchedules = ref([])
const loadingSchedules = ref(false)
// 订单数据
const pendingCount = ref(0)
const arrivedCount = ref(0)
const receivedCount = ref(0)
const loadingOrders = ref(false)
// 获取今日日程
async function fetchTodaySchedules(silent = false) {
if (!silent) loadingSchedules.value = true
try {
const res = await scheduleApi.getEvents({
start: todayStr,
end: todayStr
})
if (res.errCode === 0 && res.data?.list) {
todaySchedules.value = res.data.list
// 预加载所有创建人的用户名
const userIDs = [...new Set(res.data.list.map(s => s.UserID).filter(Boolean))]
userIDs.forEach(userID => fetchUserInfo(userID))
}
} catch (e) {
console.error('获取今日日程失败', e)
} finally {
if (!silent) loadingSchedules.value = false
}
}
// 获取订单统计
async function fetchOrderStats(silent = false) {
if (!silent) loadingOrders.value = true
try {
const res = await purchaseApi.getOrderCount()
if (res.errCode === 0 && res.data) {
pendingCount.value = res.data.pending || 0
arrivedCount.value = res.data.arrived || 0
receivedCount.value = res.data.received || 0
}
} catch (e) {
console.error('获取订单统计失败', e)
} finally {
if (!silent) loadingOrders.value = false
}
}
// 格式化日程日期
function formatScheduleDate(schedule) {
if (schedule.StartDate !== schedule.EndDate) {
return `${schedule.StartDate}\n至\n${schedule.EndDate}`
}
return schedule.StartDate
}
// 获取创建人名称(使用用户缓存)
function getCreatorName(userId) {
if (!userId) return '未知'
// 如果是当前用户,显示"我"
if (userStore.userInfo && userStore.userInfo.ID === userId) {
return userStore.username || '我'
}
// 从缓存获取用户名
return getUsername(userId)
}
// 跳转到 TabBar 页面
function switchToTab(url) {
uni.switchTab({ url })
}
// 定时刷新
let refreshTimer = null
const REFRESH_INTERVAL = 5000 // 5秒
// 开始定时刷新(静默刷新,不显示 loading)
function startRefreshTimer() {
stopRefreshTimer()
refreshTimer = setInterval(() => {
fetchTodaySchedules(true) // true = 静默刷新
if (userStore.isLoggedIn) {
fetchOrderStats(true) // true = 静默刷新
}
}, REFRESH_INTERVAL)
}
// 停止定时刷新
function stopRefreshTimer() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
// 页面显示时加载数据
onShow(() => {
// 恢复会话(如果需要)
if (!userStore.isLoggedIn) {
userStore.restoreSession()
}
// 加载数据
fetchTodaySchedules()
if (userStore.isLoggedIn) {
fetchOrderStats()
}
// 开始定时刷新
startRefreshTimer()
})
// 页面离开时停止定时器
onHide(() => {
stopRefreshTimer()
})
</script>
<style scoped>
.container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background-color: #f5f5f5;
background-color: #F5F5F5;
min-height: 100vh;
padding: 20rpx 30rpx;
}
.placeholder {
font-size: 32rpx;
/* 欢迎区域 */
.welcome-section {
padding: 30rpx 0;
}
.welcome-title {
display: block;
font-size: 44rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.welcome-date {
font-size: 28rpx;
color: #999;
}
/* 卡片 */
.card {
background-color: #FFFFFF;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.card-count {
font-size: 26rpx;
color: #007AFF;
}
/* 加载状态 */
.loading {
display: flex;
justify-content: center;
padding: 40rpx 0;
}
.loading text {
font-size: 28rpx;
color: #999;
}
/* 空状态 */
.empty {
display: flex;
justify-content: center;
padding: 60rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
/* 日程列表 */
.schedule-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.schedule-item {
display: flex;
align-items: flex-start;
background-color: #F8F9FA;
border-radius: 12rpx;
padding: 20rpx;
}
.schedule-date {
display: flex;
align-items: center;
justify-content: center;
padding: 10rpx 16rpx;
border-radius: 8rpx;
color: #FFFFFF;
font-size: 22rpx;
text-align: center;
white-space: pre-line;
line-height: 1.4;
min-width: 120rpx;
}
.schedule-content {
flex: 1;
margin-left: 20rpx;
}
.schedule-title {
display: block;
font-size: 30rpx;
color: #333;
font-weight: 500;
margin-bottom: 8rpx;
}
.schedule-user {
font-size: 24rpx;
color: #999;
}
/* 订单统计 */
.order-stats {
display: flex;
justify-content: space-around;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
}
.stat-num {
font-size: 48rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.stat-num.stat-warning {
color: #FF9500;
}
.stat-label {
font-size: 26rpx;
color: #999;
}
/* 快捷入口 */
.quick-links {
display: flex;
justify-content: space-around;
}
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 30rpx 50rpx;
background-color: #F8F9FA;
border-radius: 16rpx;
}
.quick-icon {
font-size: 60rpx;
margin-bottom: 16rpx;
}
.quick-text {
font-size: 28rpx;
color: #333;
}
</style>
+193 -48
View File
@@ -1,67 +1,148 @@
<template>
<view class="container">
<!-- Logo 区域 -->
<view class="logo-section">
<image class="logo-img" src="/static/logo.png" mode="aspectFit" />
<text class="app-name">OPS 管理系统</text>
</view>
<!-- 登录表单 -->
<view class="form">
<input class="input" type="text" v-model="username" placeholder="请输入用户名" />
<input class="input" type="password" v-model="password" placeholder="请输入密码" />
<button class="submit-btn" @click="handleLogin">登录</button>
<input
class="input"
type="text"
v-model="username"
placeholder="请输入用户名"
:disabled="loading"
/>
<input
class="input"
type="password"
v-model="password"
placeholder="请输入密码"
:disabled="loading"
@confirm="handleLogin"
/>
<!-- 记住登录 -->
<view class="remember-row">
<checkbox-group @change="onRememberChange">
<label class="remember-label">
<checkbox :checked="remember" value="1" color="#007AFF" />
<text>记住登录</text>
</label>
</checkbox-group>
</view>
<button
class="submit-btn"
@click="handleLogin"
:disabled="loading"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</view>
<!-- 错误提示 -->
<view v-if="errorMsg" class="error-tip">
{{ errorMsg }}
</view>
</view>
<my-toast ref="toast" />
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useUserStore } from '../../stores/user'
import { useConfigStore } from '../../stores/config'
import { userApi } from '../../api/user'
const useConfig =useConfigStore()
const userStore = useUserStore()
const configStore = useConfigStore()
const username = ref('')
const password = ref('')
const remember = ref(true)
const loading = ref(false)
const errorMsg = ref('')
const toast = ref(null)
const handleLogin = () => {
if (!username.value || !password.value) {
uni.showToast({
title: '请输入用户名和密码',
icon: 'none'
// 页面加载时检查是否已配置 API 地址
onMounted(() => {
const baseUrl = configStore.getApiBaseUrl()
if (!baseUrl) {
uni.showModal({
title: '提示',
content: '请先在设置页面配置 API 地址',
showCancel: false,
success: () => {
uni.navigateTo({ url: '/pages/settings/settings' })
}
})
}
})
// 记住登录切换
const onRememberChange = (e) => {
remember.value = e.detail.value.includes('1')
}
// 处理登录
const handleLogin = async () => {
// 验证输入
if (!username.value.trim()) {
errorMsg.value = '请输入用户名'
return
}
if (!password.value) {
errorMsg.value = '请输入密码'
return
}
// 检查 API 地址
const baseUrl = configStore.getApiBaseUrl()
if (!baseUrl) {
uni.showModal({
title: '提示',
content: '请先在设置页面配置 API 地址',
showCancel: false,
success: () => {
uni.navigateTo({ url: '/pages/settings/settings' })
}
})
return
}
// TODO: 调用登录接口
console.log('登录信息:', username.value, password.value,useConfig.getApiBaseUrl())
//userApi.login(username.value,password.value,true)
uni.request({
url: useConfig.getApiBaseUrl()+"/users/login",
method: 'POST',
timeout:1000,
header: {
'Content-Type': 'application/json'
},
data:{
"username": username.value,
"password": password.value,
"remember": true
},
success: (res) => {
console.log('成功', res)
switch(res.statusCode){
case 200:
toast.value.success('成功')
break;
default:
toast.value.error('服务异常')
break
}
},
fail: (err) => {
console.log('失败', err)
toast.value.error('网异常')
},
errorMsg.value = ''
loading.value = true
try {
const res = await userApi.login(username.value, password.value, remember.value)
if (res.errCode === 0 && res.data?.cookie) {
// 登录成功
toast.value.success('登录成功')
userStore.login(res.data.cookie)
// 延迟跳转,等待 Toast 显示
setTimeout(() => {
// 跳转到用户中心(TabBar 页面)
uni.switchTab({
url: '/pages/user/user'
})
}, 500)
} else {
// 登录失败,显示统一错误信息(不区分具体错误,防止暴力破解)
errorMsg.value = '用户名或密码错误'
toast.value.error(errorMsg.value)
}
} catch (e) {
console.error('登录异常', e)
errorMsg.value = '网络错误,请检查网络连接'
toast.value.error('网络错误')
} finally {
loading.value = false
}
}
</script>
@@ -69,14 +150,41 @@ const handleLogin = () => {
.container {
flex: 1;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
background-color: #f5f5f5;
padding: 40rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 100rpx 40rpx 40rpx;
}
/* Logo 区域 */
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 80rpx;
}
.logo-img {
width: 160rpx;
height: 160rpx;
margin-bottom: 20rpx;
}
.app-name {
font-size: 40rpx;
font-weight: bold;
color: #FFFFFF;
letter-spacing: 4rpx;
}
/* 表单区域 */
.form {
width: 100%;
background-color: #FFFFFF;
border-radius: 20rpx;
padding: 40rpx;
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.1);
}
.input {
@@ -84,19 +192,56 @@ const handleLogin = () => {
line-height: 90rpx;
padding: 0 30rpx;
margin-bottom: 30rpx;
background-color: #FFFFFF;
background-color: #F8F8F8;
border-radius: 10rpx;
font-size: 28rpx;
}
.input:last-of-type {
margin-bottom: 20rpx;
}
/* 记住登录 */
.remember-row {
margin-bottom: 30rpx;
}
.remember-label {
display: flex;
align-items: center;
font-size: 26rpx;
color: #666;
}
.remember-label checkbox {
margin-right: 10rpx;
}
/* 登录按钮 */
.submit-btn {
width: 100%;
height: 90rpx;
line-height: 90rpx;
background-color: #007AFF;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #FFFFFF;
font-size: 32rpx;
border-radius: 45rpx;
border: none;
}
.submit-btn[disabled] {
background: #CCCCCC;
}
/* 错误提示 */
.error-tip {
width: 100%;
margin-top: 30rpx;
padding: 20rpx;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 10rpx;
margin-top: 20rpx;
text-align: center;
color: #FF3B30;
font-size: 26rpx;
}
</style>
+679
View File
@@ -0,0 +1,679 @@
<template>
<view class="container">
<!-- 页面标题 -->
<view class="page-header">
<text class="page-title">新增订单</text>
</view>
<scroll-view scroll-y class="form-content">
<!-- 基本信息 -->
<view class="card">
<view class="card-title">基本信息</view>
<!-- 标题必填 -->
<view class="form-item">
<text class="form-label required">配件名称</text>
<input
class="form-input"
v-model="form.title"
placeholder="请输入配件名称"
maxlength="50"
/>
</view>
<!-- 备注 -->
<view class="form-item">
<text class="form-label">备注</text>
<textarea
class="form-textarea"
v-model="form.remark"
placeholder="请输入备注信息"
maxlength="256"
/>
<text class="char-count">{{ form.remark.length }}/256</text>
</view>
<!-- 采购链接 -->
<view class="form-item">
<text class="form-label">采购链接</text>
<textarea
class="form-textarea"
v-model="form.link"
placeholder="请输入采购链接"
rows="2"
/>
</view>
<!-- 款式标签 -->
<view class="form-item">
<text class="form-label">款式标签</text>
<view class="tags-input">
<view class="tag-list">
<view
class="tag"
v-for="(tag, index) in tags"
:key="index"
>
{{ tag }}
<text class="tag-remove" @click="removeTag(index)">×</text>
</view>
</view>
<input
class="tag-input"
v-model="newTag"
placeholder="输入标签后回车添加"
@confirm="addTag"
/>
</view>
</view>
</view>
<!-- 费用明细 -->
<view class="card">
<view class="card-title">费用明细</view>
<!-- 已添加的费用列表 -->
<view class="cost-table" v-if="costEntries.length > 0">
<view class="cost-header">
<text class="cost-col">类型</text>
<text class="cost-col">数量</text>
<text class="cost-col">单价</text>
<text class="cost-col">总计</text>
<text class="cost-col">货币</text>
<text class="cost-col">操作</text>
</view>
<view
class="cost-row"
v-for="(item, index) in costEntries"
:key="index"
>
<text class="cost-col">{{ costType[item.type] || item.type }}</text>
<text class="cost-col">{{ item.int }}</text>
<text class="cost-col">{{ item.cost }}</text>
<text class="cost-col">{{ item.costt }}</text>
<text class="cost-col">{{ currencyOptions[item.currencytype] || item.currencytype }}</text>
<text class="cost-col delete" @click="removeCostEntry(index)">删除</text>
</view>
</view>
<!-- 添加费用表单 -->
<view class="cost-form">
<view class="cost-form-row">
<view class="cost-field">
<text class="cost-label">费用类型</text>
<picker mode="selector" :range="costTypeOptions" range-key="label" @change="onCostTypeChange">
<view class="cost-picker">
{{ costTypeOptions[costTypeIndex].label }}
</view>
</picker>
</view>
<view class="cost-field">
<text class="cost-label">数量</text>
<input class="cost-input" type="number" v-model="newCost.int" min="1" />
</view>
</view>
<view class="cost-form-row">
<view class="cost-field">
<text class="cost-label">单价</text>
<input class="cost-input" type="digit" v-model="newCost.cost" step="0.01" />
</view>
<view class="cost-field">
<text class="cost-label">货币</text>
<picker mode="selector" :range="currencyOptionsList" range-key="label" @change="onCurrencyChange">
<view class="cost-picker">
{{ currencyOptionsList[currencyIndex].label }}
</view>
</picker>
</view>
</view>
<view class="cost-total" v-if="newCostCost > 0">
总计{{ currencyOptionsList[currencyIndex].symbol }}{{ newCostTotal }}
</view>
<view class="cost-error" v-if="costError">单价必须大于0</view>
<button class="add-cost-btn" @click="addCostEntry">添加费用</button>
</view>
</view>
<!-- 图片上传 -->
<view class="card">
<view class="card-title">图片</view>
<view class="photo-upload">
<view
v-for="(photo, index) in photos"
:key="index"
class="photo-item"
@click="previewImage(index)"
>
<image
class="photo-img"
:src="photo.url"
mode="aspectFill"
/>
<view class="photo-remove" @click.stop="removePhoto(index)">×</view>
</view>
<view v-if="photos.length < 9" class="photo-add" @click="chooseImage">
<text class="photo-add-icon">+</text>
<text class="photo-add-text">添加图片</text>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-area">
<button class="submit-btn" :disabled="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : '提交订单' }}
</button>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { purchaseApi } from '@/api/purchase.js'
import { api } from '@/api/index.js'
import { useConfigStore } from '@/stores/config.js'
const configStore = useConfigStore()
// 费用类型选项
const costTypeOptions = [
{ value: 1, label: '单价' },
{ value: 2, label: '运费' }
]
const costTypeIndex = ref(0)
const costType = { 1: '单价', 2: '运费' }
// 货币选项
const currencyOptionsList = [
{ value: 1, label: 'CNY', symbol: '¥' },
{ value: 2, label: 'MOP', symbol: 'MOP' },
{ value: 3, label: 'HKD', symbol: 'HK$' },
{ value: 4, label: 'USD', symbol: '$' }
]
const currencyOptions = { 1: 'CNY', 2: 'MOP', 3: 'HKD', 4: 'USD' }
const currencyIndex = ref(0)
// 表单数据
const form = reactive({
title: '',
remark: '',
link: '',
styles: '',
photos: []
})
// 标签
const tags = ref([])
const newTag = ref('')
// 费用明细
const costEntries = reactive([])
const newCost = reactive({
type: 1,
int: 1,
cost: 0,
currencyType: 1
})
const costError = ref(false)
// 图片
const photos = ref([])
const newCostCost = computed(() => parseFloat(newCost.cost) || 0)
const newCostTotal = computed(() => (newCost.int * newCostCost.value).toFixed(2))
// 提交状态
const submitting = ref(false)
// 添加标签
function addTag() {
if (newTag.value.trim()) {
tags.value.push(newTag.value.trim())
newTag.value = ''
form.styles = tags.value.join(',')
}
}
// 删除标签
function removeTag(index) {
tags.value.splice(index, 1)
form.styles = tags.value.join(',')
}
// 费用类型选择
function onCostTypeChange(e) {
costTypeIndex.value = e.detail.value
newCost.type = costTypeOptions[e.detail.value].value
}
// 货币类型选择
function onCurrencyChange(e) {
currencyIndex.value = e.detail.value
newCost.currencyType = currencyOptionsList[e.detail.value].value
}
// 添加费用
function addCostEntry() {
if (newCost.cost <= 0) {
costError.value = true
return
}
costError.value = false
costEntries.push({
type: newCost.type,
int: newCost.int,
cost: parseFloat(newCost.cost).toFixed(2),
costt: newCostTotal.value,
currencytype: newCost.currencyType
})
// 重置
newCost.type = 1
newCost.int = 1
newCost.cost = 0
newCost.currencyType = 1
costTypeIndex.value = 0
currencyIndex.value = 0
}
// 删除费用
function removeCostEntry(index) {
costEntries.splice(index, 1)
}
// 选择图片
function chooseImage() {
uni.chooseImage({
count: 9,
success: (res) => {
res.tempFiles.forEach(file => {
uploadImage(file.path)
})
}
})
}
// 上传图片
async function uploadImage(filePath) {
uni.showLoading({ title: '上传中...', mask: true })
try {
const res = await api.upload('/files/upload/image', { uri: filePath, name: 'file' })
if (res.errCode === 0 && res.data?.hash) {
photos.value.push({
hash: res.data.hash,
url: `${configStore.getFileBaseUrl()}/api/files/get/${res.data.hash}`
})
} else {
uni.showToast({ title: '上传失败', icon: 'none' })
}
} catch (e) {
console.error('上传失败', e)
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
// 预览图片
function previewImage(index) {
const urls = photos.value.map(p => p.url)
uni.previewImage({
urls,
current: index
})
}
// 删除图片
function removePhoto(index) {
photos.value.splice(index, 1)
}
// 提交表单
async function handleSubmit() {
// 验证必填项
if (!form.title.trim()) {
uni.showToast({ title: '请输入配件名称', icon: 'none' })
return
}
submitting.value = true
try {
// 转换费用数据(元转分)
const costs = costEntries.map(h => ({
type: h.type,
int: h.int,
cost: Math.round(parseFloat(h.cost) * 100),
costt: Math.round(parseFloat(h.costt) * 100),
currencytype: h.currencytype
}))
// 转换图片
const photoHashes = photos.value.map(p => p.hash)
const res = await purchaseApi.addOrder({
title: form.title,
remark: form.remark,
link: form.link,
styles: form.styles,
costs,
photos: photoHashes
})
if (res.errCode === 0) {
uni.showToast({ title: '提交成功', icon: 'success' })
uni.$emit('purchase-refresh')
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({ title: '提交失败', icon: 'none' })
}
} catch (e) {
console.error('提交失败', e)
uni.showToast({ title: '提交失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f5f5f5;
}
.page-header {
background-color: #fff;
padding: 30rpx;
border-bottom: 1px solid #eee;
}
.page-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
.form-content {
padding: 20rpx;
}
.card {
background-color: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.card-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1px solid #eee;
}
.form-item {
margin-bottom: 30rpx;
}
.form-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 12rpx;
}
.form-label.required::after {
content: ' *';
color: #f56c6c;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
font-size: 28rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
padding: 20rpx 24rpx;
font-size: 28rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
box-sizing: border-box;
min-height: 120rpx;
}
.char-count {
display: block;
text-align: right;
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
/* 标签输入 */
.tags-input {
background-color: #f8f8f8;
border-radius: 12rpx;
padding: 16rpx;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 12rpx;
}
.tag {
display: flex;
align-items: center;
background-color: #e6f0ff;
color: #1890ff;
padding: 8rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
}
.tag-remove {
margin-left: 8rpx;
font-size: 28rpx;
color: #999;
}
.tag-input {
width: 100%;
height: 60rpx;
font-size: 26rpx;
}
/* 费用明细 */
.cost-table {
margin-bottom: 30rpx;
border: 1px solid #eee;
border-radius: 12rpx;
overflow: hidden;
}
.cost-header,
.cost-row {
display: flex;
padding: 16rpx 12rpx;
font-size: 24rpx;
}
.cost-header {
background-color: #f8f8f8;
color: #666;
}
.cost-row {
border-top: 1px solid #eee;
color: #333;
}
.cost-col {
flex: 1;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cost-col.delete {
color: #f56c6c;
}
.cost-form {
background-color: #f8f8f8;
border-radius: 12rpx;
padding: 20rpx;
}
.cost-form-row {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
}
.cost-field {
flex: 1;
}
.cost-label {
display: block;
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.cost-input {
width: 100%;
height: 72rpx;
padding: 0 16rpx;
font-size: 28rpx;
background-color: #fff;
border-radius: 8rpx;
box-sizing: border-box;
}
.cost-picker {
height: 72rpx;
padding: 0 16rpx;
font-size: 28rpx;
background-color: #fff;
border-radius: 8rpx;
line-height: 72rpx;
box-sizing: border-box;
}
.cost-total {
text-align: right;
font-size: 28rpx;
color: #1890ff;
margin-bottom: 16rpx;
}
.cost-error {
font-size: 24rpx;
color: #f56c6c;
margin-bottom: 16rpx;
}
.add-cost-btn {
width: 100%;
height: 80rpx;
line-height: 80rpx;
background-color: #1890ff;
color: #fff;
font-size: 28rpx;
border-radius: 12rpx;
border: none;
}
/* 图片上传 */
.photo-upload {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.photo-item {
position: relative;
width: 200rpx;
height: 200rpx;
}
.photo-img {
width: 100%;
height: 100%;
border-radius: 12rpx;
}
.photo-remove {
position: absolute;
top: -12rpx;
right: -12rpx;
width: 40rpx;
height: 40rpx;
background-color: #f56c6c;
color: #fff;
border-radius: 50%;
font-size: 28rpx;
text-align: center;
line-height: 40rpx;
}
.photo-add {
width: 200rpx;
height: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border: 2rpx dashed #ddd;
border-radius: 12rpx;
}
.photo-add-icon {
font-size: 60rpx;
color: #ccc;
line-height: 1;
}
.photo-add-text {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
/* 提交按钮 */
.submit-area {
padding: 20rpx 0 40rpx;
}
.submit-btn {
width: 100%;
height: 88rpx;
background-color: #1890ff;
color: #fff;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
}
.submit-btn[disabled] {
background-color: #a0cfff;
}
</style>
+436
View File
@@ -0,0 +1,436 @@
<template>
<view class="container">
<view class="header">
<text class="back-btn" @click="goBack"> 返回</text>
<text class="title">订单详情</text>
<text v-if="canModify" class="edit-btn" @click="goEdit">编辑</text>
<view v-else class="header-right"></view>
</view>
<scroll-view scroll-y class="content" refresher-enabled @refresherrefresh="onRefresh" :refresher-triggered="refreshing">
<view v-if="loading" class="loading"><text>加载中...</text></view>
<view v-else-if="order">
<!-- 基本信息 -->
<view class="card">
<view class="card-header">
<text class="card-title">订单信息</text>
<text class="order-status" :class="order.OrderStatus">{{ getStatusText(order.OrderStatus) }}</text>
</view>
<view class="info-row">
<text class="info-label">标题</text>
<text class="info-value">{{ order.Title || '-' }}</text>
</view>
<view class="info-row" v-if="order.Link">
<text class="info-label">链接</text>
<text class="info-value link" @click="openLink">{{ order.Link }}</text>
</view>
<view class="info-row" v-if="order.Remark">
<text class="info-label">备注</text>
<text class="info-value">{{ order.Remark }}</text>
</view>
<view class="info-row">
<text class="info-label">创建时间</text>
<text class="info-value">{{ formatDate(order.CreatedAt) }}</text>
</view>
</view>
<!-- 状态切换 -->
<view class="card">
<view class="card-header"><text class="card-title">变更状态</text></view>
<view class="status-buttons">
<view v-for="opt in statusOptions" :key="opt.value" class="status-btn" :class="order.OrderStatus === opt.value ? 'active ' + opt.value : ''" @click="openStatusDialog(opt.value)">
<text>{{ opt.label }}</text>
</view>
</view>
</view>
<!-- 费用明细 -->
<view class="card" v-if="costs.length > 0">
<view class="card-header"><text class="card-title">费用明细</text></view>
<view class="cost-table">
<view class="cost-header">
<text class="cost-th">类型</text>
<text class="cost-th">数量</text>
<text class="cost-th">单价</text>
<text class="cost-th">小计</text>
</view>
<view class="cost-row" v-for="(cost, idx) in costs" :key="idx">
<text class="cost-td">{{ getCostTypeText(cost.CostType) }}</text>
<text class="cost-td">{{ cost.Quantity }}</text>
<text class="cost-td">{{ formatPrice(cost.Price) }}</text>
<text class="cost-td">{{ formatPrice(cost.Price * cost.Quantity) }}</text>
</view>
</view>
<view class="cost-total">
<text v-for="g in costsByCurrency" :key="g.currency" class="total-tag">{{ g.currency }} {{ g.total }}</text>
</view>
</view>
<!-- 图片 -->
<view class="card" v-if="photos.length > 0">
<view class="card-header"><text class="card-title">图片 ({{ photos.length }})</text></view>
<view class="photo-grid">
<view v-for="photo in photos" :key="photo.ID" class="photo-item">
<image :src="getImageUrl(photo.Sha256)" mode="aspectFill" @click="previewImages(photo.Sha256)" />
</view>
</view>
</view>
<!-- 关联工单 -->
<view class="card" v-if="workOrders.length > 0">
<view class="card-header"><text class="card-title">关联工单</text></view>
<view v-for="wo in workOrders" :key="wo.id" class="workorder-item" @click="goToWorkOrder(wo.id)">
<text class="wo-id">#{{ wo.id }}</text>
<text class="wo-title">{{ wo.title }}</text>
<text class="wo-status" :class="wo.status">{{ getWOStatusText(wo.status) }}</text>
</view>
</view>
<!-- 状态记录 -->
<view class="card" v-if="commits.length > 0">
<view class="card-header"><text class="card-title">状态记录</text></view>
<view class="timeline">
<view v-for="commit in commits" :key="commit.id" class="timeline-item">
<view class="timeline-dot" :class="commit.status"></view>
<view class="timeline-content">
<view class="timeline-header">
<text class="timeline-status" :class="commit.status">{{ getStatusText(commit.status) }}</text>
<text class="timeline-date">{{ formatDate(commit.createdAt) }}</text>
</view>
<text class="timeline-user">by {{ getUsernameById(commit.userId) }}</text>
<text class="timeline-comment" v-if="commit.comment">{{ commit.comment }}</text>
<view class="timeline-photos" v-if="commit.photos && commit.photos.length">
<image v-for="hash in commit.photos" :key="hash" class="timeline-photo" :src="getImageUrl(hash)" mode="aspectFill" @click="previewImages(hash)" />
</view>
</view>
</view>
</view>
</view>
</view>
<view v-else class="empty"><text>未找到订单信息</text></view>
</scroll-view>
<!-- 状态变更弹窗 -->
<view class="modal" v-if="statusDialogVisible" @click="closeStatusDialog">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">变更状态</text>
<text class="modal-close" @click="closeStatusDialog">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="form-label">新状态</text>
<view class="status-display">{{ getStatusText(pendingStatus) }}</view>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea v-model="pendingComment" class="form-textarea" placeholder="可选" />
</view>
<view class="form-item">
<text class="form-label">图片</text>
<view class="photo-upload-area">
<view v-for="(photo, idx) in pendingPhotos" :key="idx" class="uploaded-photo">
<image :src="photo.url" mode="aspectFill" />
<view class="photo-remove" @click="pendingPhotos.splice(idx, 1)">×</view>
</view>
<view class="add-photo-btn" @click="chooseImage" v-if="pendingPhotos.length < 9">
<text>+</text>
</view>
</view>
</view>
</view>
<view class="modal-footer">
<text class="btn-cancel" @click="closeStatusDialog">取消</text>
<text class="btn-confirm" @click="confirmStatusChange">确认</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { purchaseApi } from '@/api/purchase.js'
import { useConfigStore } from '@/stores/config.js'
import { fetchUserInfo, getUsername } from '@/stores/users.js'
import { api } from '@/api/index.js'
import { onLoad } from '@dcloudio/uni-app'
const configStore = useConfigStore()
const orderId = ref(0)
const loading = ref(false)
const order = ref(null)
const costs = ref([])
const photos = ref([])
const commits = ref([])
const workOrders = ref([])
const canModify = ref(false)
const statusOptions = [
{ value: 'pending', label: '待处理' },
{ value: 'ordered', label: '已下单' },
{ value: 'arrived', label: '已到达' },
{ value: 'received', label: '已收件' },
{ value: 'lost', label: '丢件' },
{ value: 'returned', label: '退件' }
]
const currencyOptions = { 1: 'CNY', 2: 'MOP', 3: 'HKD', 4: 'USD' }
const costTypeOptions = { 1: '单价', 2: '运费' }
const costsByCurrency = computed(() => {
const groups = {}
costs.value.forEach(c => {
const cur = currencyOptions[c.CurrencyType] || 'Unknown'
const amount = c.Price && c.Quantity ? ((c.Price * c.Quantity) / 100).toFixed(2) : '0.00'
if (!groups[cur]) groups[cur] = 0
groups[cur] += parseFloat(amount)
})
return Object.entries(groups).map(([currency, total]) => ({ currency, total: total.toFixed(2) }))
})
const statusDialogVisible = ref(false)
const pendingStatus = ref('')
const pendingComment = ref('')
const pendingPhotos = ref([])
const submitting = ref(false)
const refreshing = ref(false)
function getStatusText(status) {
return statusOptions.find(s => s.value === status)?.label || status
}
function getCurrencyText(type) { return currencyOptions[type] || '-' }
function getCostTypeText(type) { return costTypeOptions[type] || type }
function getWOStatusText(status) {
return { pending: '待处理', checked: '已检查', parts_ordered: '已下单零件', repaired: '已维修', returned: '已送还', unrepairable: '无法维修' }[status] || status
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatPrice(priceInCents) {
return priceInCents ? (priceInCents / 100).toFixed(2) : '0.00'
}
function getImageUrl(sha256) {
return configStore.getFileBaseUrl() + '/api/files/get/' + sha256
}
function previewImages(currentSha) {
const urls = photos.value.map(p => getImageUrl(p.Sha256))
const idx = photos.value.findIndex(p => p.Sha256 === currentSha)
uni.previewImage({ urls, current: idx >= 0 ? idx : 0 })
}
function getUsernameById(userId) {
return getUsername(userId) || `用户${userId}`
}
async function fetchOrderDetail() {
loading.value = true
try {
const res = await purchaseApi.getOrder({ id: orderId.value })
if (res.errCode === 0 && res.data) {
order.value = res.data.order || null
canModify.value = res.data.canModify || false
costs.value = res.data.costs || []
photos.value = res.data.photos || []
commits.value = res.data.commits || []
workOrders.value = res.data.workOrders || []
// 预取用户信息
if (order.value?.UserID) fetchUserInfo(order.value.UserID)
commits.value.forEach(c => {
if (c.userId) fetchUserInfo(c.userId)
})
} else {
order.value = null
}
} catch (e) {
console.error('获取订单详情失败', e)
order.value = null
} finally {
loading.value = false
}
}
function openStatusDialog(status) {
pendingStatus.value = status
pendingComment.value = ''
pendingPhotos.value = []
statusDialogVisible.value = true
}
function closeStatusDialog() {
statusDialogVisible.value = false
pendingStatus.value = ''
pendingComment.value = ''
pendingPhotos.value = []
}
async function chooseImage() {
uni.chooseImage({
count: 9 - pendingPhotos.value.length,
success: (res) => res.tempFilePaths.forEach(path => uploadImage(path))
})
}
async function uploadImage(filePath) {
try {
const res = await api.upload('/files/upload/image', { uri: filePath, name: 'file' })
if (res.errCode === 0 && res.data?.hash) {
pendingPhotos.value.push({ hash: res.data.hash, url: getImageUrl(res.data.hash) })
}
} catch (e) {
console.error('上传失败', e)
}
}
async function confirmStatusChange() {
if (submitting.value) return
submitting.value = true
try {
const res = await api.post('/purchase/updatestatus', {
id: orderId.value,
status: pendingStatus.value,
comment: pendingComment.value,
photos: pendingPhotos.value.map(p => p.hash)
})
if (res.errCode === 0) {
uni.showToast({ title: '状态更新成功', icon: 'success' })
closeStatusDialog()
fetchOrderDetail()
uni.$emit('purchase-refresh')
} else {
uni.showToast({ title: res.errMsg || '更新失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '更新失败', icon: 'none' })
} finally {
submitting.value = false
}
}
function goBack() { uni.navigateBack() }
function goEdit() {
uni.navigateTo({ url: `/pages/order/edit-order?id=${orderId.value}` })
}
function goToWorkOrder(id) {
uni.navigateTo({ url: `/pages/workorder/show-workorder?id=${id}` })
}
function openLink() {
if (!order.value?.Link) return
let url = order.value.Link.trim()
if (!/^https?:\/\//i.test(url)) url = 'https://' + url
uni.setClipboardData({
data: url,
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
})
}
async function onRefresh() {
refreshing.value = true
await fetchOrderDetail()
refreshing.value = false
}
onLoad((options) => {
if (options?.id) {
orderId.value = parseInt(options.id)
fetchOrderDetail()
}
})
</script>
<style scoped>
.container { min-height: 100vh; background-color: #f5f5f5; padding-bottom: 20rpx; }
.header { background-color: #fff; padding: 30rpx; display: flex; align-items: center; }
.back-btn { font-size: 32rpx; color: #007AFF; margin-right: 20rpx; }
.title { font-size: 36rpx; font-weight: bold; color: #333; flex: 1; text-align: center; }
.edit-btn { font-size: 28rpx; color: #007AFF; }
.header-right { width: 60rpx; }
.content { padding: 20rpx; height: calc(100vh - 120rpx); }
.loading, .empty { display: flex; justify-content: center; align-items: center; height: 300rpx; color: #999; }
.card { background-color: #fff; border-radius: 16rpx; margin-bottom: 20rpx; overflow: hidden; }
.card-header { padding: 24rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; }
.card-title { font-size: 30rpx; font-weight: bold; color: #333; }
.order-status { font-size: 24rpx; padding: 8rpx 20rpx; border-radius: 20rpx; }
.order-status.pending, .timeline-status.pending, .timeline-dot.pending { background-color: #fff7e6; color: #faad14; }
.order-status.ordered, .timeline-status.ordered, .timeline-dot.ordered { background-color: #e6f7ff; color: #1890ff; }
.order-status.arrived, .timeline-status.arrived, .timeline-dot.arrived { background-color: #f9f0ff; color: #722ed1; }
.order-status.received, .timeline-status.received, .timeline-dot.received { background-color: #f6ffed; color: #52c41a; }
.order-status.lost, .timeline-status.lost, .timeline-dot.lost { background-color: #fff1f0; color: #ff4d4f; }
.order-status.returned, .timeline-status.returned, .timeline-dot.returned { background-color: #f5f5f5; color: #999; }
.info-row { padding: 20rpx 30rpx; display: flex; border-bottom: 1rpx solid #f0f0f0; }
.info-label { width: 160rpx; color: #999; font-size: 28rpx; flex-shrink: 0; }
.info-value { flex: 1; color: #333; font-size: 28rpx; word-break: break-all; }
.info-value.link { color: #007AFF; }
.status-buttons { display: flex; flex-wrap: wrap; padding: 20rpx; gap: 20rpx; }
.status-btn { padding: 16rpx 30rpx; border-radius: 30rpx; background-color: #f5f5f5; color: #666; font-size: 26rpx; }
.status-btn.active { color: #fff; }
.status-btn.active.pending { background-color: #faad14; }
.status-btn.active.ordered { background-color: #1890ff; }
.status-btn.active.arrived { background-color: #722ed1; }
.status-btn.active.received { background-color: #52c41a; }
.status-btn.active.lost { background-color: #ff4d4f; }
.status-btn.active.returned { background-color: #999; }
.cost-table { padding: 0 20rpx 20rpx; }
.cost-header, .cost-row { display: flex; padding: 20rpx 10rpx; }
.cost-header { background-color: #fafafa; border-radius: 8rpx; }
.cost-th, .cost-td { flex: 1; text-align: center; font-size: 24rpx; }
.cost-th { color: #999; font-weight: 500; }
.cost-total { padding: 20rpx; border-top: 1rpx solid #f0f0f0; display: flex; gap: 20rpx; }
.total-tag { background-color: #e6f7ff; color: #1890ff; padding: 8rpx 20rpx; border-radius: 8rpx; font-size: 24rpx; }
.photo-grid { display: flex; flex-wrap: wrap; padding: 20rpx; gap: 20rpx; }
.photo-item { width: 200rpx; height: 200rpx; border-radius: 12rpx; overflow: hidden; }
.photo-item image { width: 100%; height: 100%; }
.workorder-item { padding: 24rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; display: flex; align-items: center; }
.wo-id { color: #999; font-size: 26rpx; margin-right: 16rpx; }
.wo-title { flex: 1; color: #333; font-size: 28rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wo-status { font-size: 24rpx; padding: 8rpx 16rpx; border-radius: 16rpx; margin-left: 16rpx; }
.wo-status.pending { background-color: #fff7e6; color: #faad14; }
.wo-status.checked { background-color: #e6f7ff; color: #1890ff; }
.wo-status.parts_ordered { background-color: #f9f0ff; color: #722ed1; }
.wo-status.repaired { background-color: #f6ffed; color: #52c41a; }
.wo-status.returned { background-color: #f5f5f5; color: #999; }
.wo-status.unrepairable { background-color: #fff1f0; color: #ff4d4f; }
.timeline { padding: 20rpx 30rpx; }
.timeline-item { display: flex; padding-bottom: 30rpx; position: relative; }
.timeline-item:not(:last-child)::before { content: ''; position: absolute; left: 10rpx; top: 30rpx; bottom: 0; width: 2rpx; background-color: #e5e5e5; }
.timeline-dot { width: 20rpx; height: 20rpx; border-radius: 50%; margin-top: 8rpx; flex-shrink: 0; margin-right: 20rpx; }
.timeline-content { flex: 1; }
.timeline-header { display: flex; justify-content: space-between; align-items: center; }
.timeline-date { font-size: 24rpx; color: #999; }
.timeline-user { display: block; font-size: 24rpx; color: #999; margin-top: 8rpx; }
.timeline-comment { display: block; font-size: 28rpx; color: #333; margin-top: 12rpx; }
.timeline-photos { display: flex; flex-wrap: wrap; gap: 16rpx; margin-top: 16rpx; }
.timeline-photo { width: 100rpx; height: 100rpx; border-radius: 8rpx; }
.modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: flex-end; z-index: 999; }
.modal-content { width: 100%; background-color: #fff; border-radius: 24rpx 24rpx 0 0; max-height: 80vh; }
.modal-header { padding: 30rpx; display: flex; justify-content: space-between; align-items: center; border-bottom: 1rpx solid #f0f0f0; }
.modal-title { font-size: 32rpx; font-weight: bold; color: #333; }
.modal-close { font-size: 48rpx; color: #999; line-height: 1; }
.modal-body { padding: 30rpx; max-height: 60vh; overflow-y: auto; }
.form-item { margin-bottom: 30rpx; }
.form-label { display: block; font-size: 28rpx; color: #666; margin-bottom: 16rpx; }
.status-display { font-size: 32rpx; color: #333; font-weight: 500; }
.form-textarea { width: 100%; height: 160rpx; border: 1rpx solid #e5e5e5; border-radius: 12rpx; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; }
.photo-upload-area { display: flex; flex-wrap: wrap; gap: 20rpx; }
.uploaded-photo { width: 150rpx; height: 150rpx; border-radius: 12rpx; overflow: hidden; position: relative; }
.uploaded-photo image { width: 100%; height: 100%; }
.photo-remove { position: absolute; top: -10rpx; right: -10rpx; width: 40rpx; height: 40rpx; background-color: rgba(0, 0, 0, 0.5); color: #fff; border-radius: 50%; display: flex; justify-content: center; align-items: center; font-size: 28rpx; }
.add-photo-btn { width: 150rpx; height: 150rpx; border-radius: 12rpx; border: 2rpx dashed #ddd; display: flex; justify-content: center; align-items: center; }
.add-photo-btn text { font-size: 60rpx; color: #999; }
.modal-footer { padding: 30rpx; display: flex; gap: 20rpx; border-top: 1rpx solid #f0f0f0; }
.btn-cancel, .btn-confirm { flex: 1; text-align: center; padding: 24rpx; border-radius: 12rpx; font-size: 30rpx; }
.btn-cancel { background-color: #f5f5f5; color: #666; }
.btn-confirm { background-color: #007AFF; color: #fff; }
</style>
+417 -7
View File
@@ -1,23 +1,433 @@
<template>
<view class="container">
<text class="placeholder">订单</text>
<view class="header">
<text class="title">采购订单</text>
<view class="add-btn" @click="goToAdd">
<text>+ 新增</text>
</view>
</view>
<view class="stats-row">
<view class="stat-item" @click="filterStatus('pending')">
<text class="stat-num">{{ stats.pending || 0 }}</text>
<text class="stat-label">待处理</text>
</view>
<view class="stat-item" @click="filterStatus('ordered')">
<text class="stat-num">{{ stats.ordered || 0 }}</text>
<text class="stat-label">已下单</text>
</view>
<view class="stat-item" @click="filterStatus('arrived')">
<text class="stat-num">{{ stats.arrived || 0 }}</text>
<text class="stat-label">已到达</text>
</view>
<view class="stat-item" @click="filterStatus('received')">
<text class="stat-num">{{ stats.received || 0 }}</text>
<text class="stat-label">已收件</text>
</view>
</view>
<view class="filter-bar">
<view class="search-box">
<input
class="search-input"
placeholder="搜索订单..."
v-model="searchText"
@confirm="onSearch"
/>
<text class="search-btn" @click="onSearch">搜索</text>
</view>
<picker mode="selector" :range="statusOptions" range-key="label" @change="onStatusChange">
<view class="picker">
{{ currentFilter.label || '全部状态' }}
</view>
</picker>
</view>
<scroll-view scroll-y class="order-list" refresher-enabled @refresherrefresh="onRefresh" :refresher-triggered="refreshing" @scrolltolower="loadMore">
<view v-if="loading && orders.length === 0" class="loading">
<text>加载中...</text>
</view>
<view v-else-if="orders.length === 0" class="empty">
<text>暂无订单</text>
</view>
<view v-else>
<view
v-for="item in orders"
:key="item.ID"
class="order-card"
@click="goDetail(item.ID)"
>
<view class="order-header">
<text class="order-id">#{{ item.ID }}</text>
<text class="order-status" :class="item.OrderStatus">{{ getStatusText(item.OrderStatus) }}</text>
</view>
<view class="order-content">
<text class="order-title">{{ item.Title || '无标题' }}</text>
<text class="order-remark" v-if="item.Remark">{{ item.Remark }}</text>
</view>
<view class="order-footer">
<text class="order-date">{{ formatDate(item.CreatedAt) }}</text>
<text class="order-link" v-if="item.Link">有链接</text>
</view>
</view>
<view v-if="loadingMore" class="loading-more">
<text>加载更多...</text>
</view>
<view v-else-if="hasMore" class="load-more-btn" @click="loadMore">
<text>加载更多</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { purchaseApi } from '@/api/purchase.js'
const loading = ref(false)
const loadingMore = ref(false)
const orders = ref([])
const stats = ref({})
const page = ref(1)
const pageSize = ref(10)
const hasMore = ref(false)
const currentFilter = ref({})
const searchText = ref('')
const refreshing = ref(false)
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'pending', label: '待处理' },
{ value: 'ordered', label: '已下单' },
{ value: 'arrived', label: '已到达' },
{ value: 'received', label: '已收件' },
{ value: 'lost', label: '丢件' },
{ value: 'returned', label: '退件' }
]
function getStatusText(status) {
const option = statusOptions.find(s => s.value === status)
return option ? option.label : status
}
function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
async function fetchStats() {
try {
const res = await purchaseApi.getOrderCount()
if (res.errCode === 0 && res.data) {
stats.value = res.data
}
} catch (e) {
console.error('获取订单统计失败', e)
}
}
async function fetchOrders(reset = false) {
if (reset) {
page.value = 1
orders.value = []
}
if (loading.value || loadingMore.value) return
if (!reset && !hasMore.value) return
if (reset) {
loading.value = true
} else {
loadingMore.value = true
}
try {
const params = {
entries: pageSize.value,
page: page.value
}
if (currentFilter.value.value) {
params.status = currentFilter.value.value
}
if (searchText.value) {
params.search = searchText.value
}
const res = await purchaseApi.getOrders(params)
if (res.errCode === 0 && res.data) {
const list = res.data.all_orders || []
if (reset) {
orders.value = list
} else {
orders.value = [...orders.value, ...list]
}
hasMore.value = list.length >= pageSize.value
page.value++
}
} catch (e) {
console.error('获取订单列表失败', e)
} finally {
loading.value = false
loadingMore.value = false
}
}
function onStatusChange(e) {
currentFilter.value = statusOptions[e.detail.value]
fetchOrders(true)
}
function filterStatus(status) {
currentFilter.value = statusOptions.find(s => s.value === status) || statusOptions[0]
fetchOrders(true)
}
function onSearch() {
fetchOrders(true)
}
function loadMore() {
if (hasMore.value && !loadingMore.value) {
fetchOrders(false)
}
}
function goDetail(id) {
uni.navigateTo({
url: `/pages/order/order-detail?id=${id}`
})
}
onMounted(() => {
fetchStats()
fetchOrders(true)
})
// 监听状态变更后刷新
uni.$on('purchase-refresh', () => {
fetchStats()
fetchOrders(true)
})
function goToAdd() {
uni.navigateTo({
url: '/pages/order/order-add'
})
}
async function onRefresh() {
refreshing.value = true
await Promise.all([fetchStats(), fetchOrders(true)])
refreshing.value = false
}
</script>
<style scoped>
.container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
padding-bottom: 20rpx;
}
.placeholder {
font-size: 32rpx;
.header {
background-color: #fff;
padding: 30rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.add-btn {
padding: 12rpx 24rpx;
background-color: #1890ff;
color: #fff;
font-size: 26rpx;
border-radius: 8rpx;
}
.stats-row {
display: flex;
background-color: #fff;
padding: 20rpx 10rpx;
margin-bottom: 20rpx;
}
.stat-item {
flex: 1;
text-align: center;
padding: 15rpx 0;
}
.stat-num {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #007AFF;
}
.stat-label {
display: block;
font-size: 22rpx;
color: #666;
margin-top: 8rpx;
}
.filter-bar {
background-color: #fff;
padding: 20rpx 30rpx;
margin-bottom: 20rpx;
}
.search-box {
display: flex;
margin-bottom: 20rpx;
}
.search-input {
flex: 1;
background-color: #f0f0f0;
padding: 15rpx 25rpx;
border-radius: 10rpx 0 0 10rpx;
font-size: 28rpx;
}
.search-btn {
background-color: #007AFF;
color: #fff;
padding: 15rpx 35rpx;
border-radius: 0 10rpx 10rpx 0;
font-size: 28rpx;
}
.picker {
background-color: #f0f0f0;
padding: 20rpx 30rpx;
border-radius: 10rpx;
font-size: 28rpx;
}
.order-list {
height: calc(100vh - 480rpx);
}
.loading, .empty {
text-align: center;
padding: 100rpx 0;
color: #999;
}
.order-card {
background-color: #fff;
margin: 0 20rpx 20rpx;
padding: 30rpx;
border-radius: 12rpx;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.order-id {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.order-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
background-color: #f0f0f0;
color: #666;
}
.order-status.pending {
background-color: #fff3e0;
color: #ff9800;
}
.order-status.ordered {
background-color: #e3f2fd;
color: #2196f3;
}
.order-status.arrived {
background-color: #e8f5e9;
color: #4caf50;
}
.order-status.received {
background-color: #e0f2f1;
color: #009688;
}
.order-status.lost {
background-color: #ffebee;
color: #f44336;
}
.order-status.returned {
background-color: #f3e5f5;
color: #9c27b0;
}
.order-content {
margin-bottom: 20rpx;
}
.order-title {
display: block;
font-size: 30rpx;
color: #333;
margin-bottom: 10rpx;
}
.order-remark {
display: block;
font-size: 26rpx;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-footer {
display: flex;
justify-content: space-between;
border-top: 1rpx solid #f0f0f0;
padding-top: 15rpx;
}
.order-date {
font-size: 24rpx;
color: #999;
}
.order-link {
font-size: 24rpx;
color: #007AFF;
}
.loading-more, .load-more-btn {
text-align: center;
padding: 30rpx;
color: #007AFF;
}
.load-more-btn {
background-color: #fff;
margin: 0 20rpx;
border-radius: 10rpx;
}
</style>
+251 -4
View File
@@ -1,25 +1,135 @@
<template>
<view class="container">
<!-- 顶部设置按钮 -->
<view class="header">
<text class="settings-icon" @click="goToSettings"></text>
</view>
<button class="login-btn" @click="goToLogin">登录</button>
<!-- 未登录状态 -->
<view v-if="!userStore.isLoggedIn" class="not-login">
<view class="avatar-placeholder">
<text class="avatar-icon">👤</text>
</view>
<text class="not-login-text">您还未登录</text>
<button class="login-btn" @click="goToLogin">前往登录</button>
</view>
<!-- 已登录状态 -->
<view v-else class="user-info">
<!-- 用户信息卡片 -->
<view class="user-card">
<view class="avatar-section">
<image
v-if="userStore.avatarUrl"
class="avatar-img"
:src="userStore.avatarUrl"
mode="aspectFill"
/>
<view v-else class="avatar-placeholder">
<text class="avatar-icon">👤</text>
</view>
</view>
<view class="user-detail">
<text class="username">{{ userStore.username || '未设置昵称' }}</text>
<text class="user-role">{{ getUserRole() }}</text>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view class="menu-item" @click="switchToTab('/pages/order/order')">
<text class="menu-icon">📦</text>
<text class="menu-text">我的订单</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="switchToTab('/pages/warehouse/warehouse')">
<text class="menu-icon">🏭</text>
<text class="menu-text">仓库管理</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="switchToTab('/pages/schedule/list')">
<text class="menu-icon">📅</text>
<text class="menu-text">我的日程</text>
<text class="menu-arrow"></text>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-section">
<button class="logout-btn" @click="handleLogout">退出登录</button>
</view>
</view>
</view>
</template>
<script setup>
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '../../stores/user'
const userStore = useUserStore()
// 每次显示页面时检查登录状态
onShow(() => {
// 如果未登录,尝试从存储恢复会话
if (!userStore.isLoggedIn) {
userStore.restoreSession()
}
})
// 跳转到登录页
const goToLogin = () => {
uni.navigateTo({
url: '/pages/login/login'
})
}
// 跳转到设置页
const goToSettings = () => {
uni.navigateTo({
url: '/pages/settings/settings'
})
}
// 通用跳转(支持普通页面和 TabBar 页面)
const navigateTo = (url) => {
// 检查是否是 TabBar 页面
const tabBarPages = ['/pages/index/index', '/pages/order/order', '/pages/warehouse/warehouse', '/pages/user/user']
if (tabBarPages.includes(url)) {
uni.switchTab({ url })
} else {
uni.navigateTo({ url })
}
}
// 跳转到 TabBar 页面
const switchToTab = (url) => {
uni.switchTab({ url })
}
// 获取用户角色
const getUserRole = () => {
if (!userStore.user) return ''
// 根据用户组判断角色(这里简单处理)
return '用户'
}
// 退出登录
const handleLogout = () => {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
confirmColor: '#FF3B30',
success: (res) => {
if (res.confirm) {
userStore.logout()
uni.showToast({
title: '已退出登录',
icon: 'success'
})
}
}
})
}
</script>
<style scoped>
@@ -27,10 +137,11 @@ const goToSettings = () => {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
background-color: #f5f5f5;
background-color: #F5F5F5;
min-height: 100vh;
}
/* 顶部设置按钮 */
.header {
width: 100%;
display: flex;
@@ -42,6 +153,37 @@ const goToSettings = () => {
font-size: 48rpx;
}
/* 未登录状态 */
.not-login {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
}
.avatar-placeholder {
width: 160rpx;
height: 160rpx;
border-radius: 80rpx;
background-color: #E0E0E0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30rpx;
}
.avatar-icon {
font-size: 80rpx;
}
.not-login-text {
font-size: 32rpx;
color: #666;
margin-bottom: 40rpx;
}
.login-btn {
width: 300rpx;
height: 80rpx;
@@ -50,6 +192,111 @@ const goToSettings = () => {
color: #FFFFFF;
font-size: 28rpx;
border-radius: 40rpx;
margin-top: 200rpx;
}
/* 已登录状态 */
.user-info {
flex: 1;
padding: 0 30rpx;
}
/* 用户信息卡片 */
.user-card {
display: flex;
align-items: center;
background-color: #FFFFFF;
border-radius: 20rpx;
padding: 40rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.avatar-section {
margin-right: 30rpx;
}
.avatar-img {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
}
.avatar-placeholder {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background-color: #E0E0E0;
display: flex;
align-items: center;
justify-content: center;
}
.user-detail {
display: flex;
flex-direction: column;
}
.username {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.user-role {
font-size: 26rpx;
color: #999;
}
/* 功能菜单 */
.menu-section {
background-color: #FFFFFF;
border-radius: 20rpx;
margin-bottom: 30rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.menu-item {
display: flex;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #F0F0F0;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-icon {
font-size: 40rpx;
margin-right: 20rpx;
}
.menu-text {
flex: 1;
font-size: 30rpx;
color: #333;
}
.menu-arrow {
font-size: 36rpx;
color: #CCCCCC;
}
/* 退出登录 */
.logout-section {
margin-top: 40rpx;
}
.logout-btn {
width: 100%;
height: 90rpx;
line-height: 90rpx;
background-color: #FFFFFF;
color: #FF3B30;
font-size: 30rpx;
border-radius: 45rpx;
border: none;
}
</style>
+314
View File
@@ -0,0 +1,314 @@
<template>
<view class="container">
<view class="header">
<text class="back-btn" @click="goBack"> 返回</text>
<text class="title">新增物品</text>
</view>
<scroll-view scroll-y class="content">
<view class="form-card">
<view class="form-item">
<text class="form-label required">名称 *</text>
<input class="form-input" v-model="form.name" placeholder="请输入物品名称" />
</view>
<view class="form-item">
<text class="form-label">编号</text>
<input class="form-input" v-model="form.serialNumber" placeholder="请输入物品编号(选填)" />
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea class="form-textarea" v-model="form.remark" placeholder="请输入备注(选填)" />
</view>
</view>
<view class="form-card">
<view class="form-item">
<text class="form-label">物品图片</text>
<view class="image-list">
<view
v-for="(photo, index) in form.photos"
:key="index"
class="image-item"
>
<image class="preview-img" :src="getPhotoUrl(photo)" mode="aspectFill" />
<view class="image-remove" @click="removePhoto(index)">×</view>
</view>
<view v-if="form.photos.length < 9" class="image-add" @click="chooseImage">
<text class="add-icon">+</text>
<text class="add-text">添加图片</text>
</view>
</view>
</view>
</view>
<view class="submit-section">
<view class="submit-btn" @click="submitForm">
<text>保存</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { warehouseApi } from '@/api/warehouse.js'
import { useConfigStore } from '@/stores/config.js'
import api from '@/api/index.js'
const configStore = useConfigStore()
// 容器ID(从URL参数获取)
const containerId = ref(0)
// 表单数据
const form = ref({
name: '',
serialNumber: '',
remark: '',
photos: []
})
function getPhotoUrl(sha256) {
const baseUrl = configStore.getFileBaseUrl ? configStore.getFileBaseUrl() : ''
return `${baseUrl}/api/files/get/${sha256}`
}
function goBack() {
uni.navigateBack()
}
function chooseImage() {
uni.chooseImage({
count: 9 - form.value.photos.length,
success: (res) => {
res.tempFilePaths.forEach(path => {
uploadImage(path)
})
}
})
}
async function uploadImage(filePath) {
try {
uni.showLoading({ title: '上传中...' })
const res = await api.upload('/files/upload/image', {
uri: filePath,
name: 'file'
})
if (res.errCode === 0 && res.data && res.data.hash) {
form.value.photos.push(res.data.hash)
uni.showToast({ title: '上传成功', icon: 'success' })
} else {
uni.showToast({ title: res.message || '上传失败', icon: 'none' })
}
} catch (e) {
console.error('上传失败', e)
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function removePhoto(index) {
form.value.photos.splice(index, 1)
}
async function submitForm() {
if (!form.value.name.trim()) {
uni.showToast({ title: '请输入名称', icon: 'none' })
return
}
try {
uni.showLoading({ title: '保存中...' })
const data = {
name: form.value.name,
serial_number: form.value.serialNumber,
remark: form.value.remark,
container_id: containerId.value || null,
photos: form.value.photos
}
const res = await warehouseApi.addItem(data)
if (res.errCode === 0) {
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
goBack()
}, 1500)
} else {
uni.showToast({ title: res.errMsg || '保存失败', icon: 'none' })
}
} catch (e) {
console.error('保存失败', e)
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
// 获取页面参数
import { onLoad } from '@dcloudio/uni-app'
onLoad((options) => {
console.log('add-item onLoad options:', options)
if (options && options.container_id) {
containerId.value = parseInt(options.container_id)
}
})
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f5f5f5;
}
.header {
background-color: #fff;
padding: 30rpx;
display: flex;
align-items: center;
}
.back-btn {
font-size: 32rpx;
color: #007AFF;
margin-right: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
flex: 1;
text-align: center;
padding-right: 60rpx;
}
.content {
height: calc(100vh - 120rpx);
}
.form-card {
background-color: #fff;
margin: 20rpx;
border-radius: 12rpx;
padding: 20rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 15rpx;
}
.form-label.required::after {
content: ' *';
color: #e74c3c;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
padding: 20rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 28rpx;
min-height: 150rpx;
box-sizing: border-box;
}
/* 图片上传 */
.image-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.image-item {
position: relative;
width: 180rpx;
height: 180rpx;
border-radius: 12rpx;
overflow: hidden;
}
.preview-img {
width: 100%;
height: 100%;
}
.image-remove {
position: absolute;
top: 0;
right: 0;
width: 40rpx;
height: 40rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0 0 0 12rpx;
}
.image-add {
width: 180rpx;
height: 180rpx;
background-color: #f5f5f5;
border-radius: 12rpx;
border: 2rpx dashed #ddd;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.add-icon {
font-size: 60rpx;
color: #999;
line-height: 1;
}
.add-text {
font-size: 22rpx;
color: #999;
margin-top: 10rpx;
}
.submit-section {
padding: 40rpx 20rpx;
}
.submit-btn {
background-color: #007AFF;
color: #fff;
text-align: center;
padding: 30rpx;
border-radius: 12rpx;
font-size: 32rpx;
}
</style>
+670
View File
@@ -0,0 +1,670 @@
<template>
<view class="container">
<view class="header">
<text class="back-btn" @click="goBack"> 返回</text>
<text class="title">物品详情</text>
</view>
<scroll-view scroll-y class="content" refresher-enabled @refresherrefresh="onRefresh" :refresher-triggered="refreshing">
<!-- 加载状态 -->
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<!-- 详情内容 -->
<view v-else-if="item">
<!-- 基本信息卡片 -->
<view class="card">
<view class="card-header">
<text class="card-title">基本信息</text>
</view>
<view class="info-row">
<text class="info-label">名称</text>
<text class="info-value">{{ item.Name }}</text>
</view>
<view class="info-row" v-if="item.SerialNumber">
<text class="info-label">编号</text>
<text class="info-value">{{ item.SerialNumber }}</text>
</view>
<view class="info-row" v-if="item.Specification">
<text class="info-label">规格</text>
<text class="info-value">{{ item.Specification }}</text>
</view>
<view class="info-row" v-if="item.ContainerBreadcrumb">
<text class="info-label">位置</text>
<text class="info-value location">{{ item.ContainerBreadcrumb }}</text>
</view>
<view class="info-row" v-if="item.Remark">
<text class="info-label">备注</text>
<text class="info-value">{{ item.Remark }}</text>
</view>
<view class="info-row">
<text class="info-label">创建时间</text>
<text class="info-value">{{ formatDate(item.CreatedAt) }}</text>
</view>
<view class="info-row">
<text class="info-label">更新时间</text>
<text class="info-value">{{ formatDate(item.UpdatedAt) }}</text>
</view>
</view>
<!-- 图片卡片 -->
<view class="card" v-if="photos.length > 0">
<view class="card-header">
<text class="card-title">图片 ({{ photos.length }})</text>
</view>
<view class="photo-grid">
<view
class="photo-item"
v-for="photo in photos"
:key="photo.ID"
>
<image
class="photo-img"
:src="getImageUrl(photo.Sha256)"
mode="aspectFill"
@click="previewAllImages(photo.Sha256)"
/>
</view>
</view>
</view>
<!-- 移动历史 -->
<view class="card" v-if="commits.length > 0">
<view class="card-header">
<text class="card-title">移动历史</text>
</view>
<view class="timeline">
<view
class="timeline-item"
v-for="commit in commits"
:key="commit.ID"
>
<view class="timeline-dot"></view>
<view class="timeline-content">
<text class="timeline-text">
{{ commit.OldContainerBreadcrumb || '无' }} {{ commit.NewContainerBreadcrumb || '已移除' }}
</text>
<text class="timeline-date">{{ formatDate(commit.CreatedAt) }}</text>
</view>
</view>
</view>
</view>
<!-- 关联工单 -->
<view class="card" v-if="workOrders.length > 0">
<view class="card-header">
<text class="card-title">关联工单</text>
</view>
<view
class="workorder-item"
v-for="wo in workOrders"
:key="wo.ID"
@click="goToWorkOrder(wo.id)"
>
<text class="wo-id">#{{ wo.id }}</text>
<text class="wo-title">{{ wo.title }}</text>
<text class="wo-status" :class="wo.status">{{ getWorkOrderStatusText(wo.status) }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty">
<text>未找到物品信息</text>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="action-bar" v-if="item">
<view class="action-btn workorder" @click="addWorkOrder">
<text>+ 工单</text>
</view>
<view class="action-btn edit" @click="editItem">
<text>编辑</text>
</view>
<view class="action-btn move" @click="openMoveModal">
<text>移动</text>
</view>
</view>
<!-- 移动弹窗 -->
<view class="modal" v-if="moveModalVisible" @click="closeMoveModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">移动物品</text>
<text class="modal-close" @click="closeMoveModal">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="form-label">选择容器</text>
<picker mode="selector" :range="containerOptions" range-key="Title" @change="onContainerChange">
<view class="picker">
{{ selectedContainer.Title || '请选择目标容器' }}
</view>
</picker>
</view>
</view>
<view class="modal-footer">
<text class="btn-cancel" @click="closeMoveModal">取消</text>
<text class="btn-confirm" @click="confirmMove">确定</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { warehouseApi } from '@/api/warehouse.js'
import { useConfigStore } from '@/stores/config.js'
const configStore = useConfigStore()
const itemId = ref(0)
const loading = ref(false)
const item = ref(null)
const photos = ref([])
const commits = ref([])
const workOrders = ref([])
const canModify = ref(false)
// 移动相关
const moveModalVisible = ref(false)
const containerOptions = ref([])
const selectedContainer = ref({})
const refreshing = ref(false)
const workOrderStatusOptions = [
{ value: 'pending', label: '待处理' },
{ value: 'checked', label: '已检查' },
{ value: 'parts_ordered', label: '已下单零件' },
{ value: 'repaired', label: '已维修' },
{ value: 'returned', label: '已送还' },
{ value: 'unrepairable', label: '无法维修' }
]
function getWorkOrderStatusText(status) {
const option = workOrderStatusOptions.find(s => s.value === status)
return option ? option.label : status
}
function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
function getImageUrl(sha256) {
const baseUrl = configStore.getFileBaseUrl()
return `${baseUrl}/api/files/get/${sha256}`
}
function previewAllImages(currentSha) {
const urls = photos.value.map(p => getImageUrl(p.Sha256))
const currentIndex = photos.value.findIndex(p => p.Sha256 === currentSha)
uni.previewImage({
urls: urls,
current: currentIndex >= 0 ? currentIndex : 0
})
}
async function fetchDetail() {
loading.value = true
try {
const res = await warehouseApi.getItem(itemId.value)
if (res.errCode === 0 && res.data) {
item.value = res.data.item
photos.value = res.data.photos || []
commits.value = res.data.commits || []
workOrders.value = res.data.work_orders || []
canModify.value = res.data.canModifyItem
} else {
uni.showToast({ title: '获取详情失败', icon: 'none' })
}
} catch (e) {
console.error('获取物品详情失败', e)
uni.showToast({ title: '获取详情失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function fetchContainers() {
try {
const res = await warehouseApi.listContainer({
all_levels: true,
entries: 5000,
page: 1
})
if (res.errCode === 0 && res.data) {
// 添加根目录选项,并格式化容器数据
const containers = res.data.containers || []
containerOptions.value = [
{ ID: 0, Title: '根目录' }
].concat(containers.map(c => ({
ID: c.ID,
Title: c.Title
})))
}
} catch (e) {
console.error('获取容器列表失败', e)
}
}
function openMoveModal() {
moveModalVisible.value = true
fetchContainers()
}
function closeMoveModal() {
moveModalVisible.value = false
selectedContainer.value = {}
}
function onContainerChange(e) {
selectedContainer.value = containerOptions.value[e.detail.value]
}
async function confirmMove() {
try {
uni.showLoading({ title: '移动中...' })
// ID 为 0 表示移动到根目录
const targetId = selectedContainer.value.ID === 0 ? null : selectedContainer.value.ID
const res = await warehouseApi.moveItem(itemId.value, targetId)
if (res.errCode === 0) {
uni.showToast({ title: '移动成功', icon: 'success' })
moveModalVisible.value = false
selectedContainer.value = {}
fetchDetail()
} else {
uni.showToast({ title: res.errMsg || '移动失败', icon: 'none' })
}
} catch (e) {
console.error('移动失败', e)
uni.showToast({ title: '移动失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function editItem() {
uni.navigateTo({
url: `/pages/warehouse/item-edit?id=${itemId.value}`
})
}
function addWorkOrder() {
// 存储预填数据到 storage(参考 web 端实现)
const prefillData = {
itemId: itemId.value,
title: item.value.SerialNumber
? `${item.value.Name}-${item.value.SerialNumber}`
: item.value.Name,
description: item.value.Remark || '',
}
uni.setStorageSync('prefill_work_order', JSON.stringify(prefillData))
uni.navigateTo({
url: '/pages/workorder/add-workorder'
})
}
function goToWorkOrder(id) {
uni.navigateTo({
url: `/pages/workorder/show-workorder?id=${id}`
})
}
function goBack() {
uni.navigateBack({ delta: 1 })
}
async function onRefresh() {
refreshing.value = true
await fetchDetail()
refreshing.value = false
}
onMounted(() => {
// 备用:在某些情况下 onLoad 可能未触发
})
// 使用 onLoad 获取页面参数(推荐方式)
import { onLoad, onShow } from '@dcloudio/uni-app'
onLoad((options) => {
console.log('item-detail onLoad options:', options)
if (options && options.id) {
itemId.value = parseInt(options.id)
fetchDetail()
}
})
// 每次显示页面时刷新数据(从编辑页返回时自动更新)
onShow(() => {
if (itemId.value > 0) {
fetchDetail()
}
})
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f5f5f5;
padding-bottom: 120rpx;
}
.header {
background-color: #fff;
padding: 30rpx;
display: flex;
align-items: center;
}
.back-btn {
font-size: 32rpx;
color: #007AFF;
margin-right: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
flex: 1;
text-align: center;
padding-right: 60rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.content {
height: calc(100vh - 180rpx);
}
.loading, .empty {
text-align: center;
padding: 100rpx 0;
color: #999;
}
.card {
background-color: #fff;
margin: 20rpx;
border-radius: 12rpx;
overflow: hidden;
}
.card-header {
padding: 25rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.card-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.info-row {
display: flex;
padding: 20rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
width: 150rpx;
font-size: 28rpx;
color: #666;
}
.info-value {
flex: 1;
font-size: 28rpx;
color: #333;
}
.info-value.location {
color: #007AFF;
}
.photo-grid {
padding: 20rpx 30rpx;
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.photo-item {
width: calc((100% - 30rpx) / 3);
height: 200rpx;
border-radius: 10rpx;
overflow: hidden;
}
.photo-img {
width: 100%;
height: 100%;
}
.timeline {
padding: 20rpx 30rpx;
}
.timeline-item {
display: flex;
padding: 15rpx 0;
}
.timeline-dot {
width: 16rpx;
height: 16rpx;
background-color: #007AFF;
border-radius: 50%;
margin-right: 20rpx;
margin-top: 8rpx;
}
.timeline-content {
flex: 1;
}
.timeline-text {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
}
.timeline-date {
display: block;
font-size: 24rpx;
color: #999;
}
.workorder-item {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.workorder-item:last-child {
border-bottom: none;
}
.wo-id {
font-size: 28rpx;
color: #007AFF;
margin-right: 15rpx;
}
.wo-title {
flex: 1;
font-size: 28rpx;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wo-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
background-color: #f0f0f0;
color: #666;
}
.wo-status.pending {
background-color: #fff3e0;
color: #ff9800;
}
.wo-status.checked {
background-color: #e3f2fd;
color: #2196f3;
}
.wo-status.parts_ordered {
background-color: #e8f5e9;
color: #4caf50;
}
.wo-status.repaired {
background-color: #f3e5f5;
color: #9c27b0;
}
.wo-status.returned {
background-color: #e0f2f1;
color: #009688;
}
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background-color: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
}
.action-btn {
flex: 1;
text-align: center;
padding: 25rpx;
border-radius: 10rpx;
font-size: 30rpx;
}
.action-btn.workorder {
background-color: #4CAF50;
color: #fff;
margin-right: 20rpx;
}
.action-btn.edit {
background-color: #007AFF;
color: #fff;
margin-right: 20rpx;
}
.action-btn.move {
background-color: #ff9800;
color: #fff;
}
/* 弹窗样式 */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 85%;
background-color: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.modal-close {
font-size: 50rpx;
color: #999;
}
.modal-body {
padding: 30rpx;
}
.form-item {
margin-bottom: 20rpx;
}
.form-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 15rpx;
}
.picker {
background-color: #f5f5f5;
padding: 25rpx;
border-radius: 10rpx;
font-size: 28rpx;
}
.modal-footer {
display: flex;
border-top: 1rpx solid #f0f0f0;
}
.btn-cancel, .btn-confirm {
flex: 1;
text-align: center;
padding: 30rpx;
font-size: 30rpx;
}
.btn-cancel {
color: #666;
border-right: 1rpx solid #f0f0f0;
}
.btn-confirm {
color: #007AFF;
}
</style>
+351
View File
@@ -0,0 +1,351 @@
<template>
<view class="container">
<view class="header">
<text class="back-btn" @click="goBack"> 返回</text>
<text class="title">编辑物品</text>
</view>
<scroll-view scroll-y class="content">
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<template v-else>
<view class="form-card">
<view class="form-item">
<text class="form-label required">名称 *</text>
<input class="form-input" v-model="form.name" placeholder="请输入物品名称" />
</view>
<view class="form-item">
<text class="form-label">编号</text>
<input class="form-input" v-model="form.serialNumber" placeholder="请输入物品编号(选填)" />
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea class="form-textarea" v-model="form.remark" placeholder="请输入备注(选填)" />
</view>
</view>
<view class="form-card">
<view class="form-item">
<text class="form-label">物品图片</text>
<view class="image-list">
<view
v-for="(photo, index) in form.photos"
:key="index"
class="image-item"
>
<image class="preview-img" :src="getPhotoUrl(photo)" mode="aspectFill" />
<view class="image-remove" @click="removePhoto(index)">×</view>
</view>
<view v-if="form.photos.length < 9" class="image-add" @click="chooseImage">
<text class="add-icon">+</text>
<text class="add-text">添加图片</text>
</view>
</view>
</view>
</view>
<view class="submit-section">
<view class="submit-btn" @click="submitForm">
<text>保存</text>
</view>
</view>
</template>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { warehouseApi } from '@/api/warehouse.js'
import { useConfigStore } from '@/stores/config.js'
import api from '@/api/index.js'
const configStore = useConfigStore()
// 物品ID
const itemId = ref(0)
const loading = ref(false)
// 表单数据
const form = ref({
name: '',
serialNumber: '',
remark: '',
photos: []
})
function getPhotoUrl(sha256) {
const baseUrl = configStore.getFileBaseUrl ? configStore.getFileBaseUrl() : ''
return `${baseUrl}/api/files/get/${sha256}`
}
function goBack() {
uni.navigateBack()
}
function chooseImage() {
uni.chooseImage({
count: 9 - form.value.photos.length,
success: (res) => {
res.tempFilePaths.forEach(path => {
uploadImage(path)
})
}
})
}
async function uploadImage(filePath) {
try {
uni.showLoading({ title: '上传中...' })
const res = await api.upload('/files/upload/image', {
uri: filePath,
name: 'file'
})
if (res.errCode === 0 && res.data && res.data.hash) {
form.value.photos.push(res.data.hash)
uni.showToast({ title: '上传成功', icon: 'success' })
} else {
uni.showToast({ title: res.message || '上传失败', icon: 'none' })
}
} catch (e) {
console.error('上传失败', e)
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function removePhoto(index) {
form.value.photos.splice(index, 1)
}
async function fetchDetail() {
loading.value = true
try {
const res = await warehouseApi.getItem(itemId.value)
if (res.errCode === 0 && res.data) {
const item = res.data.item
form.value = {
name: item.Name || '',
serialNumber: item.SerialNumber || '',
remark: item.Remark || '',
photos: res.data.photos ? res.data.photos.map(p => p.Sha256) : []
}
} else {
uni.showToast({ title: '获取详情失败', icon: 'none' })
}
} catch (e) {
console.error('获取物品详情失败', e)
uni.showToast({ title: '获取详情失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function submitForm() {
if (!form.value.name.trim()) {
uni.showToast({ title: '请输入名称', icon: 'none' })
return
}
try {
uni.showLoading({ title: '保存中...' })
const data = {
id: itemId.value,
name: form.value.name,
serial_number: form.value.serialNumber,
remark: form.value.remark,
photos: form.value.photos
}
const res = await warehouseApi.updateItem(data)
if (res.errCode === 0) {
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
goBack()
}, 1500)
} else {
uni.showToast({ title: res.errMsg || '保存失败', icon: 'none' })
}
} catch (e) {
console.error('保存失败', e)
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
// 获取页面参数
import { onLoad } from '@dcloudio/uni-app'
onLoad((options) => {
console.log('item-edit onLoad options:', options)
if (options && options.id) {
itemId.value = parseInt(options.id)
fetchDetail()
}
})
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f5f5f5;
}
.header {
background-color: #fff;
padding: 30rpx;
display: flex;
align-items: center;
}
.back-btn {
font-size: 32rpx;
color: #007AFF;
margin-right: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
flex: 1;
text-align: center;
padding-right: 60rpx;
}
.content {
height: calc(100vh - 120rpx);
}
.loading {
text-align: center;
padding: 100rpx 0;
color: #999;
}
.form-card {
background-color: #fff;
margin: 20rpx;
border-radius: 12rpx;
padding: 20rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 15rpx;
}
.form-label.required::after {
content: ' *';
color: #e74c3c;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
padding: 20rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 28rpx;
min-height: 150rpx;
box-sizing: border-box;
}
/* 图片上传 */
.image-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.image-item {
position: relative;
width: 180rpx;
height: 180rpx;
border-radius: 12rpx;
overflow: hidden;
}
.preview-img {
width: 100%;
height: 100%;
}
.image-remove {
position: absolute;
top: 0;
right: 0;
width: 40rpx;
height: 40rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0 0 0 12rpx;
}
.image-add {
width: 180rpx;
height: 180rpx;
background-color: #f5f5f5;
border-radius: 12rpx;
border: 2rpx dashed #ddd;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.add-icon {
font-size: 60rpx;
color: #999;
line-height: 1;
}
.add-text {
font-size: 22rpx;
color: #999;
margin-top: 10rpx;
}
.submit-section {
padding: 40rpx 20rpx;
}
.submit-btn {
background-color: #007AFF;
color: #fff;
text-align: center;
padding: 30rpx;
border-radius: 12rpx;
font-size: 32rpx;
}
</style>
File diff suppressed because it is too large Load Diff
+566
View File
@@ -0,0 +1,566 @@
<template>
<view class="container">
<view class="header">
<text class="back-btn" @click="goBack"> 返回</text>
<text class="title">新建工单</text>
<view class="header-right"></view>
</view>
<scroll-view scroll-y class="content">
<!-- 表单卡片 -->
<view class="card">
<!-- 工单标题 -->
<view class="form-item">
<text class="form-label">工单标题 <text class="required">*</text></text>
<input
v-model="form.title"
class="form-input"
type="text"
maxlength="200"
placeholder="请输入工单标题"
/>
</view>
<!-- 问题描述 -->
<view class="form-item">
<text class="form-label">问题描述</text>
<textarea
v-model="form.description"
class="form-textarea"
placeholder="请输入问题描述"
/>
</view>
<!-- 关联物品 -->
<view class="form-item">
<text class="form-label">关联物品</text>
<!-- 已选择物品 -->
<view v-if="selectedItem" class="selected-item">
<view class="selected-item-info">
<text class="selected-item-name">{{ selectedItem.Name }}</text>
<text v-if="selectedItem.SerialNumber" class="selected-item-serial">{{ selectedItem.SerialNumber }}</text>
</view>
<text class="clear-btn" @click="clearSelectedItem">清除</text>
</view>
<!-- 搜索框 -->
<view v-else class="search-box">
<input
v-model="searchQuery"
class="form-input"
type="text"
placeholder="搜索物品名称或编号"
@input="onSearchInput"
@focus="onSearchFocus"
/>
<!-- 搜索结果下拉 -->
<view v-if="showDropdown && searchResults.length > 0" class="dropdown">
<view
v-for="item in searchResults"
:key="item.ID"
class="dropdown-item"
@click="selectItem(item)"
>
<text class="dropdown-name">{{ item.Name }}</text>
<text v-if="item.SerialNumber" class="dropdown-serial">{{ item.SerialNumber }}</text>
</view>
</view>
<view v-if="showDropdown && searchLoading" class="dropdown">
<view class="dropdown-loading">搜索中...</view>
</view>
<view v-if="showDropdown && searchResults.length === 0 && !searchLoading && searchQuery.trim()" class="dropdown">
<view class="dropdown-empty">未找到匹配的物品</view>
</view>
</view>
</view>
<!-- 图片上传 -->
<view class="form-item">
<text class="form-label">图片</text>
<view class="photo-upload">
<view
v-for="(hash, index) in photos"
:key="index"
class="photo-item"
@click="previewImage(index)"
>
<image
class="photo-img"
:src="getPhotoUrl(hash)"
mode="aspectFill"
/>
<view class="photo-remove" @click.stop="removePhoto(index)">×</view>
</view>
<view v-if="photos.length < 9" class="photo-add" @click="chooseImage">
<text class="photo-add-icon">+</text>
<text class="photo-add-text">添加图片</text>
</view>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-bar">
<view class="submit-btn" :class="{ disabled: submitting }" @click="submitForm">
<text v-if="submitting">提交中...</text>
<text v-else>提交工单</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue'
import api from '@/api/index.js'
import { workOrderApi } from '@/api/work_order.js'
import { warehouseApi } from '@/api/warehouse.js'
import { useConfigStore } from '@/stores/config.js'
import { onLoad } from '@dcloudio/uni-app'
const configStore = useConfigStore()
const goBack = () => uni.navigateBack()
// 预填物品ID(从物品详情跳转时)
const presetItemId = ref(null)
onLoad((options) => {
// 优先检查预填数据(从物品详情跳转时)
const prefillStr = uni.getStorageSync('prefill_work_order')
if (prefillStr) {
try {
const prefill = JSON.parse(prefillStr)
form.title = prefill.title || ''
form.description = prefill.description || ''
if (prefill.itemId) {
presetItemId.value = prefill.itemId
fetchPresetItem(prefill.itemId)
}
uni.removeStorageSync('prefill_work_order')
} catch (e) {
console.error('读取预填数据失败', e)
}
} else if (options && options.item_id) {
// 兼容旧方式:直接传 item_id 参数
presetItemId.value = parseInt(options.item_id)
fetchPresetItem(presetItemId.value)
}
})
async function fetchPresetItem(itemId) {
try {
const res = await warehouseApi.getItem(itemId)
if (res.errCode === 0 && res.data && res.data.item) {
selectedItem.value = res.data.item
linkedItemId.value = itemId
}
} catch (e) {
console.error('获取物品信息失败', e)
}
}
// 表单数据
const form = reactive({
title: '',
description: ''
})
// 关联物品
const selectedItem = ref(null)
const linkedItemId = ref(null)
const searchQuery = ref('')
const searchResults = ref([])
const showDropdown = ref(false)
const searchLoading = ref(false)
let searchTimer = null
// 图片
const photos = ref([])
// 提交状态
const submitting = ref(false)
function onSearchInput() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
doSearch()
}, 300)
}
function onSearchFocus() {
if (searchQuery.value.trim()) {
showDropdown.value = true
}
}
async function doSearch() {
if (!searchQuery.value.trim()) {
searchResults.value = []
showDropdown.value = false
return
}
searchLoading.value = true
showDropdown.value = true
try {
const res = await warehouseApi.listItem({
search: searchQuery.value.trim()
})
if (res.errCode === 0 && res.data) {
searchResults.value = (res.data.items || []).slice(0, 10)
} else {
searchResults.value = []
}
} catch (e) {
console.error('搜索物品失败', e)
searchResults.value = []
} finally {
searchLoading.value = false
}
}
function selectItem(item) {
selectedItem.value = item
linkedItemId.value = item.ID
searchQuery.value = ''
searchResults.value = []
showDropdown.value = false
}
function clearSelectedItem() {
selectedItem.value = null
linkedItemId.value = null
}
function getPhotoUrl(hash) {
return configStore.getFileBaseUrl() + '/api/files/get/' + hash
}
function chooseImage() {
uni.chooseImage({
count: 9 - photos.value.length,
success: (res) => {
res.tempFilePaths.forEach(path => {
uploadImage(path)
})
}
})
}
async function uploadImage(filePath) {
try {
uni.showLoading({ title: '上传中...' })
const res = await api.upload('/files/upload/image', {
uri: filePath,
name: 'file'
})
if (res.errCode === 0 && res.data && res.data.hash) {
photos.value.push(res.data.hash)
uni.showToast({ title: '上传成功', icon: 'success' })
} else {
uni.showToast({ title: res.message || '上传失败', icon: 'none' })
}
} catch (e) {
console.error('上传失败', e)
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function removePhoto(index) {
photos.value.splice(index, 1)
}
function previewImage(index) {
const urls = photos.value.map(hash => getPhotoUrl(hash))
uni.previewImage({
current: index,
urls: urls
})
}
async function submitForm() {
if (!form.title.trim()) {
uni.showToast({ title: '请输入工单标题', icon: 'none' })
return
}
if (submitting.value) return
submitting.value = true
try {
const data = {
title: form.title.trim(),
description: form.description.trim(),
photos: photos.value
}
if (linkedItemId.value) {
data.item_id = linkedItemId.value
}
const res = await workOrderApi.add(data)
if (res.errCode === 0) {
uni.showToast({ title: '提交成功', icon: 'success' })
uni.$emit('workorder-refresh')
setTimeout(() => {
uni.navigateBack()
}, 500)
} else {
uni.showToast({ title: res.errMsg || '提交失败', icon: 'none' })
}
} catch (e) {
console.error('提交工单失败', e)
uni.showToast({ title: '提交失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f5f5f5;
}
.header {
background-color: #fff;
padding: 30rpx;
display: flex;
align-items: center;
}
.back-btn {
font-size: 32rpx;
color: #007AFF;
margin-right: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
flex: 1;
text-align: center;
padding-right: 60rpx;
}
.header-right {
width: 60rpx;
}
.content {
height: calc(100vh - 180rpx);
padding: 20rpx;
}
.card {
background-color: #fff;
border-radius: 12rpx;
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 15rpx;
}
.required {
color: #ff4d4f;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.selected-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
background-color: #e6f7ff;
border: 1rpx solid #91d5ff;
border-radius: 10rpx;
}
.selected-item-info {
flex: 1;
}
.selected-item-name {
display: block;
font-size: 28rpx;
color: #1890ff;
}
.selected-item-serial {
display: block;
font-size: 24rpx;
color: #8c8c8c;
margin-top: 5rpx;
}
.clear-btn {
font-size: 24rpx;
color: #ff4d4f;
padding: 10rpx;
}
.search-box {
position: relative;
}
.dropdown {
position: absolute;
top: 90rpx;
left: 0;
right: 0;
background-color: #fff;
border: 1rpx solid #e5e5e5;
border-radius: 10rpx;
max-height: 400rpx;
overflow-y: auto;
z-index: 100;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.dropdown-item {
padding: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-name {
display: block;
font-size: 28rpx;
color: #333;
}
.dropdown-serial {
display: block;
font-size: 24rpx;
color: #999;
margin-top: 5rpx;
}
.dropdown-loading,
.dropdown-empty {
padding: 30rpx;
text-align: center;
font-size: 26rpx;
color: #999;
}
.photo-upload {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.photo-item {
width: 200rpx;
height: 200rpx;
border-radius: 10rpx;
overflow: hidden;
position: relative;
}
.photo-img {
width: 100%;
height: 100%;
}
.photo-remove {
position: absolute;
top: 0;
right: 0;
width: 40rpx;
height: 40rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.photo-add {
width: 200rpx;
height: 200rpx;
background-color: #f5f5f5;
border: 2rpx dashed #d9d9d9;
border-radius: 10rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.photo-add-icon {
font-size: 60rpx;
color: #999;
line-height: 1;
}
.photo-add-text {
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
.submit-bar {
margin-top: 40rpx;
padding: 0 20rpx;
}
.submit-btn {
background-color: #007AFF;
color: #fff;
font-size: 32rpx;
text-align: center;
padding: 30rpx;
border-radius: 12rpx;
}
.submit-btn.disabled {
background-color: #ccc;
}
</style>
+491
View File
@@ -0,0 +1,491 @@
<template>
<view class="container">
<view class="header">
<text class="back-btn" @click="goBack"> 返回</text>
<text class="title">编辑工单</text>
<text class="delete-btn" @click="handleDelete">删除</text>
</view>
<scroll-view scroll-y class="content">
<!-- 加载中 -->
<view v-if="pageLoading" class="loading">
<text>加载中...</text>
</view>
<!-- 错误 -->
<view v-else-if="pageError" class="error">
<text>{{ pageError }}</text>
</view>
<!-- 表单 -->
<view v-else class="card">
<!-- 工单标题 -->
<view class="form-item">
<text class="form-label">工单标题 <text class="required">*</text></text>
<input
v-model="form.title"
class="form-input"
type="text"
maxlength="200"
placeholder="请输入工单标题"
/>
</view>
<!-- 问题描述 -->
<view class="form-item">
<text class="form-label">问题描述</text>
<textarea
v-model="form.description"
class="form-textarea"
placeholder="请输入问题描述"
/>
</view>
<!-- 图片上传 -->
<view class="form-item">
<text class="form-label">图片</text>
<view class="photo-upload">
<view
v-for="(photo, index) in photos"
:key="photo.ID || index"
class="photo-item"
@click="previewPhoto(index)"
>
<image
class="photo-img"
:src="getPhotoUrl(photo.Sha256)"
mode="aspectFill"
/>
<view class="photo-remove" @click.stop="removePhoto(index)">×</view>
</view>
<view v-if="photos.length < 9" class="photo-add" @click="chooseImage">
<text class="photo-add-icon">+</text>
<text class="photo-add-text">添加图片</text>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-bar">
<view class="submit-btn" :class="{ disabled: submitting }" @click="submitForm">
<text v-if="submitting">保存中...</text>
<text v-else>保存修改</text>
</view>
</view>
</view>
</scroll-view>
<!-- 删除确认 -->
<view v-if="showDeleteConfirm" class="mask" @click="showDeleteConfirm = false">
<view class="confirm-dialog" @click.stop>
<text class="confirm-title">确认删除</text>
<text class="confirm-content">确定要删除这个工单吗此操作无法撤销</text>
<view class="confirm-btns">
<view class="confirm-cancel" @click="showDeleteConfirm = false">取消</view>
<view class="confirm-ok" @click="doDelete">删除</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { workOrderApi } from '@/api/work_order.js'
import api from '@/api/index.js'
import { useConfigStore } from '@/stores/config.js'
const configStore = useConfigStore()
const goBack = () => uni.navigateBack()
// 路由参数
let orderId = null
// 状态
const pageLoading = ref(true)
const pageError = ref('')
const submitting = ref(false)
const showDeleteConfirm = ref(false)
// 表单数据
const form = reactive({
title: '',
description: ''
})
const photos = ref([])
// 获取图片 URL
function getPhotoUrl(hash) {
return configStore.getFileBaseUrl() + '/api/files/get/' + hash
}
// 预览图片
function previewPhoto(index) {
const urls = photos.value.map(p => getPhotoUrl(p.Sha256))
uni.previewImage({
current: index,
urls: urls
})
}
// 加载工单数据
async function fetchOrder() {
pageLoading.value = true
try {
const res = await workOrderApi.get(orderId)
if (res.errCode === 0 && res.data) {
const order = res.data.order || {}
form.title = order.Title || ''
form.description = order.Description || ''
photos.value = res.data.photos || []
} else {
pageError.value = '工单不存在'
}
} catch (e) {
console.error('获取工单失败', e)
pageError.value = '加载失败'
} finally {
pageLoading.value = false
}
}
// 选择图片
function chooseImage() {
uni.chooseImage({
count: 9 - photos.value.length,
success: (res) => {
res.tempFilePaths.forEach(path => {
uploadImage(path)
})
}
})
}
// 上传图片
async function uploadImage(filePath) {
try {
uni.showLoading({ title: '上传中...' })
const res = await api.upload('/files/upload/image', {
uri: filePath,
name: 'file'
})
if (res.errCode === 0 && res.data && res.data.hash) {
photos.value.push({ Sha256: res.data.hash })
uni.showToast({ title: '上传成功', icon: 'success' })
} else {
uni.showToast({ title: '上传失败', icon: 'none' })
}
} catch (e) {
console.error('上传失败', e)
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
// 移除图片
function removePhoto(index) {
photos.value.splice(index, 1)
}
// 提交表单
async function submitForm() {
if (!form.title.trim()) {
uni.showToast({ title: '请输入工单标题', icon: 'none' })
return
}
if (submitting.value) return
submitting.value = true
try {
const data = {
id: orderId,
title: form.title.trim(),
description: form.description.trim(),
photos: photos.value.map(p => p.Sha256)
}
const res = await workOrderApi.update(data)
if (res.errCode === 0) {
uni.showToast({ title: '保存成功', icon: 'success' })
uni.$emit('page-refresh')
setTimeout(() => {
uni.navigateBack()
}, 500)
} else {
uni.showToast({ title: res.errMsg || '保存失败', icon: 'none' })
}
} catch (e) {
console.error('保存失败', e)
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
submitting.value = false
}
}
// 删除工单
function handleDelete() {
showDeleteConfirm.value = true
}
async function doDelete() {
try {
const res = await workOrderApi.delete(orderId)
if (res.errCode === 0) {
uni.showToast({ title: '删除成功', icon: 'success' })
uni.$emit('page-refresh')
setTimeout(() => {
uni.navigateBack()
}, 500)
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
} catch (e) {
console.error('删除失败', e)
uni.showToast({ title: '删除失败', icon: 'none' })
}
showDeleteConfirm.value = false
}
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || currentPage.$page?.options || {}
orderId = options.id ? parseInt(options.id) : null
if (orderId) {
fetchOrder()
} else {
pageError.value = '工单不存在'
pageLoading.value = false
}
})
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f5f5f5;
}
.header {
background-color: #fff;
padding: 30rpx;
display: flex;
align-items: center;
}
.back-btn {
font-size: 32rpx;
color: #007AFF;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
flex: 1;
text-align: center;
}
.delete-btn {
font-size: 28rpx;
color: #ff4d4f;
}
.content {
height: calc(100vh - 120rpx);
padding: 20rpx;
}
.loading, .error {
text-align: center;
padding: 100rpx 0;
color: #999;
}
.error {
color: #ff4d4f;
}
.card {
background-color: #fff;
border-radius: 12rpx;
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 15rpx;
}
.required {
color: #ff4d4f;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.photo-upload {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.photo-item {
width: 200rpx;
height: 200rpx;
border-radius: 10rpx;
overflow: hidden;
position: relative;
}
.photo-img {
width: 100%;
height: 100%;
}
.photo-remove {
position: absolute;
top: 0;
right: 0;
width: 40rpx;
height: 40rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.photo-add {
width: 200rpx;
height: 200rpx;
background-color: #f5f5f5;
border: 2rpx dashed #d9d9d9;
border-radius: 10rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.photo-add-icon {
font-size: 60rpx;
color: #999;
line-height: 1;
}
.photo-add-text {
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
.submit-bar {
margin-top: 40rpx;
}
.submit-btn {
background-color: #007AFF;
color: #fff;
font-size: 32rpx;
text-align: center;
padding: 30rpx;
border-radius: 12rpx;
}
.submit-btn.disabled {
background-color: #ccc;
}
/* 删除确认弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
width: 560rpx;
background-color: #fff;
border-radius: 16rpx;
padding: 40rpx;
}
.confirm-title {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #333;
text-align: center;
margin-bottom: 20rpx;
}
.confirm-content {
display: block;
font-size: 28rpx;
color: #666;
text-align: center;
margin-bottom: 40rpx;
}
.confirm-btns {
display: flex;
gap: 20rpx;
}
.confirm-cancel {
flex: 1;
text-align: center;
padding: 24rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
font-size: 32rpx;
color: #666;
}
.confirm-ok {
flex: 1;
text-align: center;
padding: 24rpx;
background-color: #ff4d4f;
border-radius: 10rpx;
font-size: 32rpx;
color: #fff;
}
</style>
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
<template>
<view class="container">
<view class="header">
<text class="title">工单管理</text>
<text class="add-btn" @click="goAddWorkOrder">+ 新建</text>
</view>
<view class="stats-row">
<view class="stat-item" @click="filterStatus('pending')">
<text class="stat-num">{{ stats.pending || 0 }}</text>
<text class="stat-label">待处理</text>
</view>
<view class="stat-item" @click="filterStatus('checked')">
<text class="stat-num">{{ stats.checked || 0 }}</text>
<text class="stat-label">已检查</text>
</view>
<view class="stat-item" @click="filterStatus('parts_ordered')">
<text class="stat-num">{{ stats.parts_ordered || 0 }}</text>
<text class="stat-label">已下单零件</text>
</view>
<view class="stat-item" @click="filterStatus('repaired')">
<text class="stat-num">{{ stats.repaired || 0 }}</text>
<text class="stat-label">已维修</text>
</view>
</view>
<view class="filter-bar">
<picker mode="selector" :range="statusOptions" range-key="label" @change="onStatusChange">
<view class="picker">
{{ currentFilter.label || '全部状态' }}
</view>
</picker>
</view>
<scroll-view scroll-y class="order-list" refresher-enabled @refresherrefresh="onRefresh" :refresher-triggered="refreshing" @scrolltolower="loadMore">
<view v-if="loading && workOrders.length === 0" class="loading">
<text>加载中...</text>
</view>
<view v-else-if="workOrders.length === 0" class="empty">
<text>暂无工单</text>
</view>
<view v-else>
<view
v-for="item in workOrders"
:key="item.ID"
class="order-card"
@click="goDetail(item.ID)"
>
<view class="order-header">
<text class="order-id">#{{ item.ID }}</text>
<text class="order-status" :class="item.CurrentStatus">{{ getStatusText(item.CurrentStatus) }}</text>
</view>
<view class="order-content">
<text class="order-title">{{ item.Title || '无标题' }}</text>
<text class="order-desc">{{ item.Description || '无描述' }}</text>
</view>
<view class="order-footer">
<text class="order-date">创建: {{ formatDate(item.CreatedAt) }}</text>
</view>
</view>
<view v-if="loadingMore" class="loading-more">
<text>加载更多...</text>
</view>
<view v-else-if="hasMore" class="load-more-btn" @click="loadMore">
<text>加载更多</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { workOrderApi } from '@/api/work_order.js'
const loading = ref(false)
const loadingMore = ref(false)
const workOrders = ref([])
const stats = ref({})
const page = ref(1)
const pageSize = ref(10)
const hasMore = ref(false)
const currentFilter = ref({})
const refreshing = ref(false)
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'pending', label: '待处理' },
{ value: 'checked', label: '已检查' },
{ value: 'parts_ordered', label: '已下单零件' },
{ value: 'repaired', label: '已维修' },
{ value: 'returned', label: '已送还' },
{ value: 'unrepairable', label: '无法维修' }
]
function getStatusText(status) {
const option = statusOptions.find(s => s.value === status)
return option ? option.label : status
}
function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
async function fetchStats() {
try {
const res = await workOrderApi.getCount()
if (res.errCode === 0 && res.data) {
stats.value = res.data
}
} catch (e) {
console.error('获取工单统计失败', e)
}
}
async function fetchWorkOrders(reset = false) {
if (reset) {
page.value = 1
workOrders.value = []
}
if (loading.value || loadingMore.value) return
if (!reset && !hasMore.value) return
if (reset) {
loading.value = true
} else {
loadingMore.value = true
}
try {
const params = {
page: page.value,
entries: pageSize.value
}
if (currentFilter.value.value) {
params.status = currentFilter.value.value
}
const res = await workOrderApi.list(params)
if (res.errCode === 0 && res.data) {
const list = res.data.all_orders || []
if (reset) {
workOrders.value = list
} else {
workOrders.value = [...workOrders.value, ...list]
}
hasMore.value = list.length >= pageSize.value
page.value++
}
} catch (e) {
console.error('获取工单列表失败', e)
} finally {
loading.value = false
loadingMore.value = false
}
}
function onStatusChange(e) {
currentFilter.value = statusOptions[e.detail.value]
fetchWorkOrders(true)
}
function filterStatus(status) {
currentFilter.value = statusOptions.find(s => s.value === status) || statusOptions[0]
fetchWorkOrders(true)
}
function loadMore() {
if (hasMore.value && !loadingMore.value) {
fetchWorkOrders(false)
}
}
function goDetail(id) {
uni.navigateTo({
url: `/pages/workorder/show-workorder?id=${id}`
})
}
function goAddWorkOrder() {
uni.navigateTo({
url: '/pages/workorder/add-workorder'
})
}
async function onRefresh() {
refreshing.value = true
await Promise.all([fetchStats(), fetchWorkOrders(true)])
refreshing.value = false
}
onMounted(() => {
fetchStats()
fetchWorkOrders(true)
})
// 监听新增/编辑工单后刷新
uni.$on('workorder-refresh', () => {
fetchStats()
fetchWorkOrders(true)
})
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f5f5f5;
padding-bottom: 20rpx;
}
.header {
background-color: #fff;
padding: 30rpx;
text-align: center;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.add-btn {
font-size: 28rpx;
color: #007AFF;
position: absolute;
right: 30rpx;
}
.stats-row {
display: flex;
background-color: #fff;
padding: 20rpx 10rpx;
margin-bottom: 20rpx;
}
.stat-item {
flex: 1;
text-align: center;
padding: 15rpx 0;
}
.stat-num {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #007AFF;
}
.stat-label {
display: block;
font-size: 22rpx;
color: #666;
margin-top: 8rpx;
}
.filter-bar {
background-color: #fff;
padding: 20rpx 30rpx;
margin-bottom: 20rpx;
}
.picker {
background-color: #f0f0f0;
padding: 20rpx 30rpx;
border-radius: 10rpx;
font-size: 28rpx;
}
.order-list {
height: calc(100vh - 400rpx);
}
.loading, .empty {
text-align: center;
padding: 100rpx 0;
color: #999;
}
.order-card {
background-color: #fff;
margin: 0 20rpx 20rpx;
padding: 30rpx;
border-radius: 12rpx;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.order-id {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.order-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
background-color: #f0f0f0;
color: #666;
}
.order-status.pending {
background-color: #fef3c7;
color: #b45309;
}
.order-status.checked {
background-color: #f3e8ff;
color: #7c3aed;
}
.order-status.parts_ordered {
background-color: #dbeafe;
color: #1d4ed8;
}
.order-status.repaired {
background-color: #dcfce7;
color: #15803d;
}
.order-status.returned {
background-color: #e5e7eb;
color: #4b5563;
}
.order-status.unrepairable {
background-color: #fee2e2;
color: #dc2626;
}
.order-content {
margin-bottom: 20rpx;
}
.order-title {
display: block;
font-size: 30rpx;
color: #333;
margin-bottom: 10rpx;
}
.order-desc {
display: block;
font-size: 26rpx;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-footer {
border-top: 1rpx solid #f0f0f0;
padding-top: 15rpx;
}
.order-date {
font-size: 24rpx;
color: #999;
}
.loading-more, .load-more-btn {
text-align: center;
padding: 30rpx;
color: #007AFF;
}
.load-more-btn {
background-color: #fff;
margin: 0 20rpx;
border-radius: 10rpx;
}
</style>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

+10
View File
@@ -26,6 +26,15 @@ export const useConfigStore = defineStore('config', () => {
return apiBaseUrl.value
}
// 获取图片基础 URL(去掉末尾的 /api 部分)
const getFileBaseUrl = () => {
const base = getApiBaseUrl()
if (base) {
return base.replace(/\/api$/, '')
}
return base
}
// 设置主题
const setTheme = (newTheme) => {
@@ -39,6 +48,7 @@ export const useConfigStore = defineStore('config', () => {
theme,
setApiBaseUrl,
getApiBaseUrl,
getFileBaseUrl,
setTheme
}
})
+146 -13
View File
@@ -1,18 +1,151 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useConfigStore } from './config'
export const useUserStore = defineStore('user', {
state: () => ({
username: '',
token: ''
}),
actions: {
setUser(username, token) {
this.username = username
this.token = token
},
logout() {
this.username = ''
this.token = ''
// Storage Keys
const STORAGE_KEY_COOKIE = 'userCookie'
const STORAGE_KEY_COOKIE_SESSION = 'userCookieSession'
/**
* 加载 JSON(永久存储)
*/
function loadJson(key) {
try {
const raw = uni.getStorageSync(key)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
/**
* 清除存储
*/
function removeStorage() {
uni.removeStorageSync(STORAGE_KEY_COOKIE)
uni.removeStorageSync(STORAGE_KEY_COOKIE_SESSION)
}
export const useUserStore = defineStore('user', () => {
// ── State ──
const userCookie = ref(null) // Cookie 对象 { Value, Name, ExpiresAt, Remember }
const user = ref(null) // 用户基本信息 { ID, Name, ... }
const userInfo = ref(null) // 用户详细信息 { Username, FirstName, ... }
// ── Getters ──
/** Cookie 值字符串 */
const cookieValue = computed(() => userCookie.value?.Value ?? '')
/** 是否已登录 */
const isLoggedIn = computed(() => !!userCookie.value)
/** 用户名 */
const username = computed(() => {
return userInfo.value?.Username || user.value?.Name || ''
})
/** 头像 URL */
const avatarUrl = computed(() => {
if (userInfo.value?.AvatarPath) {
const configStore = useConfigStore()
return configStore.getApiBaseUrl() + '/static/avatar/' + userInfo.value.AvatarPath
}
return null
})
// ── Actions ──
/**
* 登录 - 保存 Cookie 并获取用户信息
* @param {Object} cookie - 后端返回的 cookie 对象
*/
function login(cookie) {
userCookie.value = cookie
// 持久化存储
if (cookie.Remember) {
uni.setStorageSync(STORAGE_KEY_COOKIE, JSON.stringify(cookie))
}
// 会话存储(始终保存)
uni.setStorageSync(STORAGE_KEY_COOKIE_SESSION, JSON.stringify(cookie))
// 检查 cookie 是否过期
if (cookie.ExpiresAt && new Date(cookie.ExpiresAt) < new Date()) {
logout()
return
}
// 获取用户信息
fetchUserInfo()
}
/**
* 退出登录
*/
function logout() {
userCookie.value = null
user.value = null
userInfo.value = null
removeStorage()
}
/**
* 获取用户信息
*/
async function fetchUserInfo() {
try {
const { api } = await import('../api/index.js')
const res = await api.post('/users/getinfo', {})
if (res.errCode === 0 && res.data) {
user.value = res.data.user ?? null
userInfo.value = res.data.userInfo ?? null
}
} catch (e) {
console.error('获取用户信息失败', e)
}
}
/**
* 应用启动时恢复登录状态
*/
function restoreSession() {
// 优先使用会话存储,否则用永久存储
let cookieStr = uni.getStorageSync(STORAGE_KEY_COOKIE_SESSION)
if (!cookieStr) {
cookieStr = uni.getStorageSync(STORAGE_KEY_COOKIE)
}
if (cookieStr) {
try {
const cookie = JSON.parse(cookieStr)
// 检查是否过期
if (cookie.ExpiresAt && new Date(cookie.ExpiresAt) < new Date()) {
logout()
return
}
// 直接设置状态并获取用户信息
userCookie.value = cookie
fetchUserInfo()
} catch {
logout()
}
}
}
return {
// State
userCookie,
user,
userInfo,
// Getters
cookieValue,
isLoggedIn,
username,
avatarUrl,
// Actions
login,
logout,
fetchUserInfo,
restoreSession
}
})
+60
View File
@@ -0,0 +1,60 @@
/**
* 用户信息缓存
*/
import { ref } from 'vue'
import { usersApi } from '../api/users'
// 全局缓存
const usersInfo = ref({})
// 请求中的 promiseMap,防止重复请求
const inflightRequests = {}
/**
* 根据用户ID获取用户信息(带缓存)
* @param {number} userID
* @returns {Promise<{Username, UserEmail} | null>}
*/
export async function fetchUserInfo(userID) {
if (!userID) return null
// 已有缓存
if (usersInfo.value[userID]) {
return usersInfo.value[userID]
}
// 请求中,等待完成
if (inflightRequests[userID]) {
return inflightRequests[userID]
}
// 发起请求
const promise = usersApi.getUserInfoFromUserID(userID).then((res) => {
if (res.errCode === 0 && res.raw?.return?.userinfo) {
const info = res.raw.return.userinfo
usersInfo.value[userID] = info
return info
}
return null
}).finally(() => {
delete inflightRequests[userID]
})
inflightRequests[userID] = promise
return promise
}
/**
* 根据用户ID获取用户名(同步,需先调用 fetchUserInfo
* @param {number} userID
* @returns {string}
*/
export function getUsername(userID) {
if (!userID) return ''
const user = usersInfo.value[userID]
return user?.Username || `用户${userID}`
}
export default {
fetchUserInfo,
getUsername
}