纳入 track-uniapp 前端项目 (扫码/任务树/记录/转交Picker)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -45,6 +45,5 @@ backend/data/
|
||||
*.tmp
|
||||
|
||||
# ===== 排除独立项目/个人文档 =====
|
||||
track-uniapp/
|
||||
track1.0.md
|
||||
PROJECT_STATUS.md
|
||||
|
||||
6
track-uniapp/.gitignore
vendored
Normal file
6
track-uniapp/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
unpackage/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
10
track-uniapp/.hbuilderx/launch.json
Normal file
10
track-uniapp/.hbuilderx/launch.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"version" : "1.0",
|
||||
"configurations" : [
|
||||
{
|
||||
"customPlaygroundType" : "device",
|
||||
"playground" : "standard",
|
||||
"type" : "uni-app:app-android"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
track-uniapp/App.vue
Normal file
23
track-uniapp/App.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch() {
|
||||
console.log("生产流转 T1.0.0 启动");
|
||||
// 无 Token 跳转登录页
|
||||
const token = uni.getStorageSync("token");
|
||||
if (!token) {
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
}
|
||||
},
|
||||
onShow() {},
|
||||
onHide() {},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
page {
|
||||
background-color: #f3f4f6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
12
track-uniapp/index.html
Normal file
12
track-uniapp/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>生产流转</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
track-uniapp/main.js
Normal file
7
track-uniapp/main.js
Normal file
@ -0,0 +1,7 @@
|
||||
import App from "./App.vue";
|
||||
import { createSSRApp } from "vue";
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
return { app };
|
||||
}
|
||||
43
track-uniapp/manifest.json
Normal file
43
track-uniapp/manifest.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "Track",
|
||||
"appid": "__UNI__B572616",
|
||||
"description": "Track - 生产流转管理",
|
||||
"versionName": "T1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"vueVersion": "3",
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueCompiler": "uni-app",
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>"
|
||||
]
|
||||
},
|
||||
"orientation": ["portrait-primary"]
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": ""
|
||||
},
|
||||
"title": "生产流转"
|
||||
}
|
||||
}
|
||||
10901
track-uniapp/package-lock.json
generated
Normal file
10901
track-uniapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
track-uniapp/pages.json
Normal file
73
track-uniapp/pages.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scan/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "扫码干活",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/tasks/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的任务",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息通知",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "生产流转",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"backgroundColor": "#F3F4F6"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#9CA3AF",
|
||||
"selectedColor": "#2563EB",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/scan/index",
|
||||
"text": "扫码干活"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/tasks/index",
|
||||
"text": "我的任务"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/notify/index",
|
||||
"text": "消息"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
68
track-uniapp/pages/login/login.vue
Normal file
68
track-uniapp/pages/login/login.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="logo">🏭</text>
|
||||
<text class="title">Track</text>
|
||||
<text class="version">T1.0.0</text>
|
||||
</view>
|
||||
|
||||
<view class="form">
|
||||
<input v-model="username" class="input" placeholder="用户名" />
|
||||
<input v-model="password" class="input" type="password" placeholder="密码" />
|
||||
<button class="login-btn" @tap="handleLogin" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { post } from "../../utils/request";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = "请输入用户名和密码";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await post("/auth/login", {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
// 保存 Token + 用户信息
|
||||
uni.setStorageSync("token", res.access_token);
|
||||
uni.setStorageSync("user", JSON.stringify(res.user));
|
||||
uni.showToast({ title: "登录成功", icon: "success" });
|
||||
// 跳转到扫码页
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: "/pages/scan/index" });
|
||||
}, 500);
|
||||
} catch {
|
||||
// request.js 已弹 toast
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; background: #f3f4f6; }
|
||||
.header { display: flex; flex-direction: column; align-items: center; margin-bottom: 40px; }
|
||||
.logo { font-size: 64px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; margin-top: 12px; }
|
||||
.version { font-size: 12px; color: #9ca3af; margin-top: 4px; }
|
||||
.form { width: 100%; max-width: 320px; }
|
||||
.input { width: 100%; height: 48px; padding: 0 16px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 15px; background: #fff; margin-bottom: 12px; box-sizing: border-box; }
|
||||
.login-btn { width: 100%; height: 48px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 16px; font-weight: 700; line-height: 48px; }
|
||||
.login-btn[disabled] { opacity: 0.6; }
|
||||
.error { display: block; text-align: center; color: #dc2626; font-size: 13px; margin-top: 12px; }
|
||||
</style>
|
||||
25
track-uniapp/pages/notify/index.vue
Normal file
25
track-uniapp/pages/notify/index.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">消息通知</text>
|
||||
<text class="subtitle">任务流转和系统通知</text>
|
||||
</view>
|
||||
<view class="empty">
|
||||
<text class="empty-icon">🔔</text>
|
||||
<text class="empty-text">暂无新消息</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
</style>
|
||||
48
track-uniapp/pages/profile/index.vue
Normal file
48
track-uniapp/pages/profile/index.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="user-card">
|
||||
<view class="avatar">{{ initial }}</view>
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ user?.display_name || '未登录' }}</text>
|
||||
<text class="user-role">{{ user?.role === 'admin' ? '管理员' : '操作员' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-card">
|
||||
<view v-for="item in ['工作统计', '设置', '帮助与反馈', '关于']" :key="item" class="menu-item">
|
||||
<text class="menu-text">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||
<view class="version">T1.0.0</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
const user = ref(null);
|
||||
try { const r = uni.getStorageSync("user"); if (r) user.value = JSON.parse(r); } catch {}
|
||||
const initial = computed(() => (user.value?.display_name || "?")[0]);
|
||||
|
||||
function handleLogout() {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("user");
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.user-card { display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.avatar { width: 48px; height: 48px; border-radius: 50%; background: #dbeafe; color: #2563EB; font-size: 20px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||||
.user-name { font-size: 16px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.user-role { font-size: 12px; color: #9ca3af; }
|
||||
.menu-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; }
|
||||
.menu-item { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f3f4f6; }
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
|
||||
</style>
|
||||
162
track-uniapp/pages/scan/index.vue
Normal file
162
track-uniapp/pages/scan/index.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- ======== 扫码按钮 ======== -->
|
||||
<view class="scan-area">
|
||||
<view class="scan-btn" @tap="handleScanCode">
|
||||
<text class="scan-icon">📷</text>
|
||||
<text class="scan-text">点击扫码</text>
|
||||
<text class="scan-hint">调用原生摄像头扫描二维码/条码</text>
|
||||
</view>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<view class="manual-input">
|
||||
<input
|
||||
v-model="serialNumber"
|
||||
class="input"
|
||||
type="text"
|
||||
maxlength="16"
|
||||
placeholder="手动输入16位序列号"
|
||||
@confirm="handleManualSearch"
|
||||
/>
|
||||
<button class="search-btn" @tap="handleManualSearch" :disabled="loading">
|
||||
{{ loading ? '查询中' : '查询' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ======== 最近扫描 ======== -->
|
||||
<view v-if="lastScanned" class="last-scan">
|
||||
最近扫描: <text class="sn-text">{{ lastScanned }}</text>
|
||||
</view>
|
||||
|
||||
<!-- ======== 加载中 / 错误 / 结果 ======== -->
|
||||
<view v-if="loading" class="loading">查询中...</view>
|
||||
<view v-if="error && !loading" class="error-box">{{ error }}</view>
|
||||
|
||||
<view v-if="product && !loading" class="result">
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📦 产品信息</text>
|
||||
<text :class="['status', statusClass(product.status)]">{{ statusLabel(product.status) }}</text>
|
||||
</view>
|
||||
<view class="info-grid">
|
||||
<view class="info-item"><text class="label">序列号</text><text class="value sn">{{ product.serial_number }}</text></view>
|
||||
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no }}</text></view>
|
||||
<view v-if="product.material_id" class="info-item"><text class="label">物料ID</text><text class="value">{{ product.material_id }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📋 当前进度</text>
|
||||
<text class="task-count">{{ product.top_level_tasks.length }} 个任务</text>
|
||||
</view>
|
||||
<view v-if="product.top_level_tasks.length === 0" class="empty-tasks">暂无关联任务</view>
|
||||
<view v-for="task in product.top_level_tasks" :key="task.id" class="task-item">
|
||||
<view class="task-info">
|
||||
<text class="task-name">{{ task.task_name }}</text>
|
||||
<text class="task-assignee">负责人: {{ task.assignee_id || '未分配' }}</text>
|
||||
</view>
|
||||
<text :class="['status-sm', statusClass(task.status)]">{{ statusLabel(task.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!product && !loading && !error" class="empty">
|
||||
<text class="empty-icon">📱</text>
|
||||
<text class="empty-text">扫码或手动输入序列号查询产品进度</text>
|
||||
</view>
|
||||
|
||||
<view class="version">T1.0.0</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { get } from "../../utils/request";
|
||||
|
||||
const serialNumber = ref("");
|
||||
const lastScanned = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const product = ref(null);
|
||||
|
||||
const STATUS_MAP = { pending: "待处理", in_progress: "进行中", completed: "已完成", cancelled: "已取消" };
|
||||
function statusLabel(s) { return STATUS_MAP[s] || s; }
|
||||
function statusClass(s) {
|
||||
switch (s) {
|
||||
case "pending": return "status-yellow";
|
||||
case "in_progress": return "status-blue";
|
||||
case "completed": return "status-green";
|
||||
default: return "status-gray";
|
||||
}
|
||||
}
|
||||
|
||||
async function doQuery(sn) {
|
||||
if (!sn || sn.length < 8) { error.value = "序列号至少需要 8 位"; return; }
|
||||
serialNumber.value = sn;
|
||||
lastScanned.value = sn;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
product.value = null;
|
||||
try { product.value = await get(`/products/scan/${sn}`); }
|
||||
catch { /* request.js 已弹 toast */ }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
function handleScanCode() {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ["qrCode", "barCode"],
|
||||
success(res) {
|
||||
const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
|
||||
doQuery(sn);
|
||||
},
|
||||
fail(err) {
|
||||
if (err.errMsg && err.errMsg.includes("cancel")) return;
|
||||
uni.showToast({ title: "扫码失败,请重试", icon: "none" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleManualSearch() { doQuery(serialNumber.value.trim()); }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.scan-btn { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 180px; background: linear-gradient(135deg, #2563EB, #3B82F6); border-radius: 16px; color: #fff; box-shadow: 0 4px 16px rgba(37,99,235,0.3); }
|
||||
.scan-icon { font-size: 44px; margin-bottom: 6px; }
|
||||
.scan-text { font-size: 18px; font-weight: 700; }
|
||||
.scan-hint { font-size: 12px; opacity: 0.8; margin-top: 4px; }
|
||||
.manual-input { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.input { flex: 1; height: 44px; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 14px; background: #fff; }
|
||||
.search-btn { height: 44px; padding: 0 20px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; line-height: 44px; }
|
||||
.search-btn[disabled] { opacity: 0.6; }
|
||||
.last-scan { font-size: 12px; color: #9ca3af; margin: 12px 0; }
|
||||
.sn-text { font-family: monospace; color: #4b5563; }
|
||||
.loading { text-align: center; padding: 32px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.card-title { font-size: 15px; font-weight: 700; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.label { font-size: 12px; color: #9ca3af; }
|
||||
.value { font-size: 14px; color: #1f2937; font-weight: 600; }
|
||||
.sn { font-family: monospace; }
|
||||
.status { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
|
||||
.status-sm { font-size: 11px; padding: 2px 8px; border-radius: 20px; font-weight: 600; flex-shrink: 0; }
|
||||
.status-yellow { background: #fef3c7; color: #b45309; }
|
||||
.status-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-green { background: #dcfce7; color: #15803d; }
|
||||
.status-gray { background: #f3f4f6; color: #6b7280; }
|
||||
.task-count { font-size: 12px; color: #9ca3af; }
|
||||
.empty-tasks { text-align: center; padding: 24px 0; color: #9ca3af; font-size: 13px; }
|
||||
.task-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-top: 1px solid #f3f4f6; }
|
||||
.task-info { flex: 1; min-width: 0; }
|
||||
.task-name { font-size: 14px; font-weight: 600; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-assignee { font-size: 12px; color: #9ca3af; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 60px; color: #9ca3af; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; padding: 16px 0; }
|
||||
</style>
|
||||
25
track-uniapp/pages/tasks/index.vue
Normal file
25
track-uniapp/pages/tasks/index.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">我的任务</text>
|
||||
<text class="subtitle">待处理和进行中的任务</text>
|
||||
</view>
|
||||
<view class="empty">
|
||||
<text class="empty-icon">📋</text>
|
||||
<text class="empty-text">暂无待办任务</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
</style>
|
||||
25
track-uniapp/src/App.vue
Normal file
25
track-uniapp/src/App.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
|
||||
|
||||
onLaunch(() => {
|
||||
console.log("生产流转 App 启动");
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
console.log("App 显示");
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
console.log("App 隐藏");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式 */
|
||||
page {
|
||||
background-color: #f3f4f6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
7
track-uniapp/src/main.js
Normal file
7
track-uniapp/src/main.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { createSSRApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
return { app };
|
||||
}
|
||||
34
track-uniapp/src/manifest.json
Normal file
34
track-uniapp/src/manifest.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "生产流转",
|
||||
"appid": "__UNI__B572616",
|
||||
"description": "工厂生产流转管理系统",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "1",
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
"routerMode": "hash",
|
||||
"title": "生产流转"
|
||||
}
|
||||
}
|
||||
74
track-uniapp/src/pages.json
Normal file
74
track-uniapp/src/pages.json
Normal file
@ -0,0 +1,74 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/scan/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "产品详情",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scan/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "扫码干活",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/tasks/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的任务",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息通知",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "生产流转",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"backgroundColor": "#F3F4F6"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#9CA3AF",
|
||||
"selectedColor": "#2563EB",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/scan/index",
|
||||
"text": "扫码干活"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/tasks/index",
|
||||
"text": "我的任务"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/notify/index",
|
||||
"text": "消息"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
25
track-uniapp/src/pages/notify/index.vue
Normal file
25
track-uniapp/src/pages/notify/index.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">消息通知</text>
|
||||
<text class="subtitle">任务流转和系统通知</text>
|
||||
</view>
|
||||
<view class="empty">
|
||||
<text class="empty-icon">🔔</text>
|
||||
<text class="empty-text">暂无新消息</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
</style>
|
||||
69
track-uniapp/src/pages/profile/index.vue
Normal file
69
track-uniapp/src/pages/profile/index.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 用户卡片 -->
|
||||
<view class="user-card">
|
||||
<view class="avatar">张</view>
|
||||
<view class="user-info">
|
||||
<text class="user-name">张三</text>
|
||||
<text class="user-role">操作员</text>
|
||||
</view>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<view class="menu-card">
|
||||
<view v-for="item in menuItems" :key="item" class="menu-item">
|
||||
<text class="menu-text">{{ item }}</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="version">生产流转 v1.0.0</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const menuItems = ["工作统计", "设置", "帮助与反馈", "关于"];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.avatar {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #dbeafe;
|
||||
color: #2563EB;
|
||||
font-size: 20px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.user-name { font-size: 16px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.user-role { font-size: 12px; color: #9ca3af; }
|
||||
.arrow { color: #d1d5db; font-size: 20px; margin-left: auto; }
|
||||
|
||||
.menu-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.version { text-align: center; font-size: 12px; color: #d1d5db; margin-top: 32px; }
|
||||
</style>
|
||||
204
track-uniapp/src/pages/scan/components/TaskTreeNode.vue
Normal file
204
track-uniapp/src/pages/scan/components/TaskTreeNode.vue
Normal file
@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<view class="tnode">
|
||||
<view v-if="hasChildren" class="tree-line-vertical" />
|
||||
|
||||
<!-- 卡片 -->
|
||||
<view :class="['tnode-card', statusColor(task.status), { 'is-rework': task.is_rework }]">
|
||||
<!-- 信息区 -->
|
||||
<view class="tnode-body">
|
||||
<view class="tnode-name-row">
|
||||
<text v-if="task.is_rework" class="tag tag-rework">⚠返工</text>
|
||||
<text v-if="task.child_tasks && task.child_tasks.length > 1" class="tag tag-fission">裂变×{{ task.child_tasks.length }}</text>
|
||||
<text class="tnode-name">{{ task.task_name }}</text>
|
||||
</view>
|
||||
<view class="tnode-meta">
|
||||
<text v-if="task.assignee_id">负责人: {{ task.assignee_id }}</text>
|
||||
<text v-if="task.reject_reason" class="reject-reason">驳回: {{ task.reject_reason }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 进度记录 — 浅灰背景独立区域 -->
|
||||
<view v-if="task.records && task.records.length" class="records-area">
|
||||
<view v-for="rec in task.records" :key="rec.id" class="record-item">
|
||||
<!-- 顶部:时间 + 编辑/删除 -->
|
||||
<view class="record-top">
|
||||
<text class="record-time">{{ formatTime(rec.created_at) }}</text>
|
||||
<view v-if="canEditRecord" class="record-actions">
|
||||
<text class="rec-act" @tap.stop="$emit('editRecord', { task, record: rec })">✏️</text>
|
||||
<text class="rec-act" @tap.stop="confirmDelete(rec)">🗑️</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="rec.remark" class="record-remark">{{ rec.remark }}</text>
|
||||
<view v-if="rec.images && rec.images.length" class="record-images">
|
||||
<image
|
||||
v-for="(img, i) in rec.images"
|
||||
:key="i"
|
||||
:src="imageUrl(img)"
|
||||
class="record-thumb"
|
||||
mode="aspectFill"
|
||||
@tap.stop="previewImage(rec.images, i)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 状态标签 -->
|
||||
<text :class="['badge', statusColor(task.status)]">{{ statusLabel(task.status) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<view v-if="task.status === 'PENDING' || task.status === 'WIP'" class="action-bar">
|
||||
<button v-if="task.status === 'PENDING'" class="act-btn act-receive" size="mini"
|
||||
@tap.stop="$emit('action', { task, type: 'receive' })">接收</button>
|
||||
<button v-if="task.status === 'PENDING'" class="act-btn act-reject" size="mini"
|
||||
@tap.stop="$emit('action', { task, type: 'reject' })">驳回</button>
|
||||
<button v-if="task.status === 'WIP'" class="act-btn act-transfer" size="mini"
|
||||
@tap.stop="$emit('action', { task, type: 'transfer' })">转交</button>
|
||||
<button v-if="task.status === 'WIP'" class="act-btn act-record" size="mini"
|
||||
@tap.stop="$emit('action', { task, type: 'record' })">📝 记录/拍照</button>
|
||||
</view>
|
||||
|
||||
<!-- 递归子树 -->
|
||||
<view v-if="task.child_tasks && task.child_tasks.length" class="tnode-children">
|
||||
<TaskTreeNode
|
||||
v-for="(child, idx) in task.child_tasks"
|
||||
:key="child.id"
|
||||
:task="child"
|
||||
:currentUser="currentUser"
|
||||
:currentUserId="currentUserId"
|
||||
:currentUsername="currentUsername"
|
||||
:isLast="idx === task.child_tasks.length - 1"
|
||||
@action="(e) => $emit('action', e)"
|
||||
@editRecord="(e) => $emit('editRecord', e)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TaskTreeNode",
|
||||
props: {
|
||||
task: { type: Object, required: true },
|
||||
isLast: { type: Boolean, default: false },
|
||||
hasChildren: { type: Boolean, default: false },
|
||||
currentUser: { type: Object, default: null },
|
||||
currentUserId: { type: [String, Number], default: "" },
|
||||
currentUsername: { type: String, default: "" },
|
||||
},
|
||||
emits: ["action", "editRecord"],
|
||||
computed: {
|
||||
canEditRecord() {
|
||||
// 双重宽松匹配:assignee_id 可能是数字ID或用户名字符串
|
||||
if (this.task.assignee_id == this.currentUserId) return true;
|
||||
if (this.task.assignee_id == this.currentUsername) return true;
|
||||
if (this.currentUser && this.currentUser.id == this.task.assignee_id) return true;
|
||||
if (this.currentUser && this.currentUser.username == this.task.assignee_id) return true;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
imageUrl(url) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http")) return url;
|
||||
// 后端返回 /api/v1/upload/files/xxx.jpg,需要补全域名
|
||||
const DOMAIN = "http://192.168.9.80:8011";
|
||||
return DOMAIN + (url.startsWith("/") ? url : "/" + url);
|
||||
},
|
||||
previewImage(urls, index) {
|
||||
const fullUrls = (urls || []).map(img => this.imageUrl(img));
|
||||
uni.previewImage({ urls: fullUrls, current: index });
|
||||
},
|
||||
confirmDelete(rec) {
|
||||
uni.showModal({
|
||||
title: "删除记录",
|
||||
content: "确定删除这条记录吗?",
|
||||
success: (res) => {
|
||||
if (res.confirm) this.$emit("action", { task: this.task, type: "deleteRecord", record: rec });
|
||||
},
|
||||
});
|
||||
},
|
||||
statusLabel(s) {
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
return map[s] || s;
|
||||
},
|
||||
statusColor(s) {
|
||||
switch (s) {
|
||||
case "PENDING": return "s-yellow";
|
||||
case "WIP": return "s-blue";
|
||||
case "COMPLETED": return "s-green";
|
||||
case "REJECTED": return "s-red";
|
||||
default: return "s-gray";
|
||||
}
|
||||
},
|
||||
formatTime(t) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tnode { position: relative; padding-left: 24px; margin-bottom: 4px; }
|
||||
.tree-line-vertical { position: absolute; left: 8px; top: 28px; bottom: 0; width: 2px; background: #e5e7eb; }
|
||||
|
||||
.tnode-card {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
padding: 10px 12px; border-radius: 10px 10px 0 0;
|
||||
background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.is-rework { border-left-color: #ef4444 !important; }
|
||||
.s-yellow { border-left-color: #f59e0b; }
|
||||
.s-blue { border-left-color: #3b82f6; }
|
||||
.s-green { border-left-color: #22c55e; }
|
||||
.s-red { border-left-color: #ef4444; }
|
||||
|
||||
.tnode-body { flex: 1; min-width: 0; }
|
||||
.tnode-name-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.tnode-name { font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.tag { font-size: 10px; padding: 1px 5px; border-radius: 6px; font-weight: 700; color: #fff; }
|
||||
.tag-rework { background: #ef4444; }
|
||||
.tag-fission { background: #7c3aed; }
|
||||
.tnode-meta { margin-top: 2px; font-size: 11px; color: #9ca3af; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.reject-reason { color: #ef4444; }
|
||||
|
||||
/* 进度记录 */
|
||||
.records-area { background-color: #f9f9f9; padding: 12rpx 16rpx; border-radius: 10rpx; margin-top: 16rpx; margin-bottom: 6rpx; }
|
||||
.record-item { padding: 8rpx 0; border-bottom: 1px dashed #e5e7eb; }
|
||||
.record-item:last-child { border-bottom: none; }
|
||||
.record-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.record-actions { display: flex; gap: 12rpx; }
|
||||
.rec-act { font-size: 28rpx; padding: 4rpx; }
|
||||
.record-remark { font-size: 26rpx; color: #333; display: block; margin: 6rpx 0; line-height: 1.5; word-break: break-all; }
|
||||
.record-images { display: flex; gap: 8rpx; margin-top: 8rpx; flex-wrap: wrap; }
|
||||
.record-thumb { width: 100rpx; height: 100rpx; border-radius: 8rpx; border: 1px solid #e5e7eb; background: #f3f4f6; }
|
||||
.record-time { font-size: 22rpx; color: #9ca3af; }
|
||||
|
||||
.badge {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
|
||||
white-space: nowrap; flex-shrink: 0; margin-left: 8px;
|
||||
background: #f3f4f6; color: #6b7280;
|
||||
}
|
||||
.s-yellow .badge { background: #fef3c7; color: #b45309; }
|
||||
.s-blue .badge { background: #dbeafe; color: #1d4ed8; }
|
||||
.s-green .badge { background: #dcfce7; color: #15803d; }
|
||||
.s-red .badge { background: #fce4ec; color: #be123c; }
|
||||
|
||||
.action-bar {
|
||||
display: flex; justify-content: flex-end; gap: 20rpx;
|
||||
padding: 10px 12px; border-top: 1px solid #f3f4f6;
|
||||
border-radius: 0 0 10px 10px; background: #fafafa;
|
||||
}
|
||||
.act-btn { height: 56rpx; line-height: 56rpx; padding: 0 28rpx; font-size: 26rpx; font-weight: 600; border-radius: 10rpx; border: none; margin: 0; }
|
||||
.act-btn::after { border: none; }
|
||||
.act-receive { background: #dbeafe; color: #2563eb; }
|
||||
.act-reject { background: #fce4ec; color: #dc2626; }
|
||||
.act-transfer { background: #dcfce7; color: #16a34a; }
|
||||
.act-record { background: #fef3c7; color: #b45309; }
|
||||
|
||||
.tnode-children { position: relative; }
|
||||
</style>
|
||||
767
track-uniapp/src/pages/scan/detail.vue
Normal file
767
track-uniapp/src/pages/scan/detail.vue
Normal file
@ -0,0 +1,767 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- ======== 加载/错误 ======== -->
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-if="error" class="error-box">{{ error }}</view>
|
||||
|
||||
<template v-if="product && !loading">
|
||||
<!-- ================================================================ -->
|
||||
<!-- 宏观状态栏 -->
|
||||
<!-- ================================================================ -->
|
||||
<view class="overall-bar" @tap="showStatusPicker = true">
|
||||
<text class="overall-label">宏观状态</text>
|
||||
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">
|
||||
{{ product.overall_status || '点击设定' }}
|
||||
</text>
|
||||
<text class="overall-arrow">▾</text>
|
||||
</view>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 产品卡片 -->
|
||||
<!-- ================================================================ -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📦 产品信息</text>
|
||||
<view class="card-header-right">
|
||||
<text :class="['badge', statusColor(product.status)]">{{ statusLabel(product.status) }}</text>
|
||||
<text class="edit-btn" @tap="openEditProduct">✏️</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-grid">
|
||||
<view class="info-item">
|
||||
<text class="label">序列号</text>
|
||||
<text class="value sn">{{ product.serial_number }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">物料名称</text>
|
||||
<text class="value">{{ product.material_name || product.material_id || '—' }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">规格型号</text>
|
||||
<text class="value">{{ product.spec_model || '—' }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">订单编号</text>
|
||||
<text class="value">{{ product.order_no || '—' }}</text>
|
||||
</view>
|
||||
<view class="info-item" v-if="product.current_location_id">
|
||||
<text class="label">当前位置</text>
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">
|
||||
{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 任务树 -->
|
||||
<!-- ================================================================ -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">🌿 任务流转树</text>
|
||||
<text class="task-count">{{ countTasks(product.task_tree) }} 个任务</text>
|
||||
</view>
|
||||
|
||||
<!-- 0任务 → 发起首道工序 -->
|
||||
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
|
||||
<text class="zero-icon">📋</text>
|
||||
<text class="zero-text">该产品暂无流转任务</text>
|
||||
<button class="btn-start" @tap="openCreateFirstTask">🚀 发起首道工序</button>
|
||||
</view>
|
||||
|
||||
<!-- 递归树 -->
|
||||
<TaskTreeNode
|
||||
v-for="(task, idx) in (product.task_tree || [])"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:currentUser="currentUser"
|
||||
:currentUserId="currentUserId"
|
||||
:currentUsername="currentUsername"
|
||||
:hasChildren="task.child_tasks && task.child_tasks.length > 0"
|
||||
:isLast="idx === (product.task_tree || []).length - 1"
|
||||
@action="handleTaskAction"
|
||||
@editRecord="openEditRecord"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 弹窗层 -->
|
||||
<!-- ================================================================ -->
|
||||
|
||||
<!-- 1. 状态定调 -->
|
||||
<view v-if="showStatusPicker" class="overlay" @tap="() => {}">
|
||||
<view class="sheet">
|
||||
<text class="sheet-title">{{ product && product.overall_status ? '修改宏观状态' : '🔔 请设定产品宏观状态' }}</text>
|
||||
<text class="sheet-hint">首次扫码,请选择一个状态以开启流转</text>
|
||||
<view class="sheet-options">
|
||||
<view v-for="opt in OVERALL_OPTIONS" :key="opt"
|
||||
:class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']"
|
||||
@tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
|
||||
</view>
|
||||
<button v-if="product && product.overall_status" class="sheet-close" @tap="showStatusPicker = false">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 2. 编辑产品 -->
|
||||
<view v-if="editProductVisible" class="overlay" @tap="editProductVisible = false">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">编辑产品</text>
|
||||
<input v-model="editForm.order_no" class="popup-input" placeholder="订单编号" />
|
||||
<input v-model="editForm.external_serial" class="popup-input" placeholder="外部序列号" />
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="editProductVisible = false">取消</button>
|
||||
<button class="btn-primary" :disabled="editSaving" @tap="doEditProduct">
|
||||
{{ editSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. 发起首道工序 -->
|
||||
<view v-if="createFirstVisible" class="overlay" @tap="createFirstVisible = false">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">🚀 发起首道工序</text>
|
||||
|
||||
<!-- 工序名称 Picker -->
|
||||
<view class="field-label">工序名称 <text class="required">*</text></view>
|
||||
<picker :range="TASK_NAME_OPTIONS" :value="firstForm.taskNameIdx" @change="onTaskNameChange">
|
||||
<view class="picker-box">{{ firstForm.task_name || '请选择工序名称' }}</view>
|
||||
</picker>
|
||||
|
||||
<!-- 接收人 Picker -->
|
||||
<view class="field-label">接收人 <text class="required">*</text></view>
|
||||
<picker :range="userLabels" :value="firstForm.assigneeIdx" @change="onAssigneeChange">
|
||||
<view class="picker-box">{{ firstForm.assigneeLabel || '请选择接收人' }}</view>
|
||||
</picker>
|
||||
|
||||
<!-- 备注(必填) -->
|
||||
<view class="field-label">备注 <text class="required">*</text></view>
|
||||
<textarea v-model="firstForm.note" class="popup-textarea" placeholder="请填写备注说明(必填)" :maxlength="500" />
|
||||
|
||||
<!-- 立即接收开关 -->
|
||||
<label class="switch-row" @tap="firstForm.autoReceive = !firstForm.autoReceive">
|
||||
<text class="switch-label">⚡ 立即接收并开始计时</text>
|
||||
<switch :checked="firstForm.autoReceive" color="#2563EB" style="transform:scale(0.8)" />
|
||||
</label>
|
||||
<text class="switch-hint">{{ firstForm.autoReceive ? '创建后自动调用接收接口,任务直接变为 WIP 并记录开工时间' : '仅创建任务(PENDING),由工人自行接收' }}</text>
|
||||
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="createFirstVisible = false">取消</button>
|
||||
<button class="btn-primary" :disabled="firstSaving || !firstForm.task_name || !firstForm.assignee_id || !firstForm.note.trim()" @tap="doCreateFirstTask">
|
||||
{{ firstSaving ? '创建中...' : (firstForm.autoReceive ? '创建并接收' : '确认创建') }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 4. 记录/拍照 -->
|
||||
<view v-if="recordPopup.visible" class="overlay" @tap="closeRecordPopup">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">{{ recordForm.recordId ? '✏️ 编辑记录' : '📝 记录/拍照' }}</text>
|
||||
<text class="popup-task">{{ recordPopup.task && recordPopup.task.task_name }}</text>
|
||||
<textarea v-model="recordForm.remark" class="popup-textarea" placeholder="填写备注说明" :maxlength="2000" />
|
||||
|
||||
<!-- 图片九宫格预览 -->
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell">
|
||||
<image :src="img" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" />
|
||||
<text v-if="canDeleteImage" class="img-del" @tap.stop="removeRecordImage(i)">✕</text>
|
||||
</view>
|
||||
<!-- 上传中占位 -->
|
||||
<view v-for="n in recordForm.pendingCount" :key="'p'+n" class="img-cell img-cell-loading">
|
||||
<text class="img-loading-text">⏳</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上传按钮 (未满9张时显示) -->
|
||||
<button
|
||||
v-if="recordForm.images.length + recordForm.pendingCount < 9"
|
||||
class="btn-upload" @tap="handleChooseImage" :disabled="isUploading"
|
||||
>
|
||||
{{ isUploading ? '上传中...' : `📷 拍照/选图 (${recordForm.images.length + recordForm.pendingCount}/9)` }}
|
||||
</button>
|
||||
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeRecordPopup">取消</button>
|
||||
<button class="btn-primary" :disabled="recordSaving || isUploading" @tap="doSaveRecord">
|
||||
{{ isUploading ? `上传中 (${recordForm.images.length}/${recordForm.images.length + recordForm.pendingCount})` : (recordSaving ? '保存中...' : (recordForm.recordId ? '更新记录' : '保存记录')) }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 5. 任务操作弹出 -->
|
||||
<view v-if="actionPopup.visible" class="overlay" @tap="closeActionPopup">
|
||||
<view class="popup" @tap.stop>
|
||||
<!-- 接收 -->
|
||||
<template v-if="actionPopup.type === 'receive'">
|
||||
<text class="popup-title">确认接收任务</text>
|
||||
<view class="popup-task">{{ actionPopup.task && actionPopup.task.task_name }}</view>
|
||||
<text class="popup-hint">状态: {{ statusLabel(actionPopup.task && actionPopup.task.status) }} → 进行中</text>
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
|
||||
<button class="btn-primary" :disabled="actionLoading" @tap="doReceive">确认接收</button>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 驳回 -->
|
||||
<template v-if="actionPopup.type === 'reject'">
|
||||
<text class="popup-title">品质驳回</text>
|
||||
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
|
||||
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text>
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
|
||||
<button class="btn-danger" :disabled="actionLoading || !rejectReason.trim()" @tap="doReject">确认驳回</button>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 转交 -->
|
||||
<template v-if="actionPopup.type === 'transfer'">
|
||||
<text class="popup-title">完工转交</text>
|
||||
|
||||
<!-- 工序选择 (Picker) -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">下一道工序 <text class="required">*</text></text>
|
||||
<picker mode="selector" :range="processOptions" @change="onProcessChange">
|
||||
<view class="picker-value">
|
||||
<text :class="transferForm.next_task_name ? '' : 'picker-placeholder'">
|
||||
{{ transferForm.next_task_name || '请选择下一道工序' }}
|
||||
</text>
|
||||
<text class="picker-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 接收人选择 (Picker,单选) -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">接收人 <text class="required">*</text></text>
|
||||
<picker mode="selector" :range="userOptions" range-key="name" @change="onUserChange">
|
||||
<view class="picker-value">
|
||||
<text :class="selectedUserName ? '' : 'picker-placeholder'">
|
||||
{{ selectedUserName || '请选择接收人' }}
|
||||
</text>
|
||||
<text class="picker-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 同时入库 -->
|
||||
<label class="wh-label" @tap="transferForm.warehouse = !transferForm.warehouse">
|
||||
<checkbox :checked="transferForm.warehouse" style="transform:scale(0.8)" /> 同时入库 (virtual_warehouse)
|
||||
</label>
|
||||
|
||||
<input v-model="transferForm.note" class="popup-input" placeholder="交接备注(选填)" />
|
||||
|
||||
<view v-if="transferForm.assignees.length && transferForm.next_task_name" class="preview-hint">
|
||||
将创建 {{ transferForm.assignees.length }}{{ transferForm.warehouse ? ' (+仓库)' : '' }} 个「{{ transferForm.next_task_name }}」任务
|
||||
</view>
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
|
||||
<button class="btn-primary" :disabled="actionLoading || !transferForm.next_task_name || (!transferForm.assignees.length && !transferForm.warehouse)" @tap="doTransfer">确认转交</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import request, { get, post, patch, put } from "../../utils/request";
|
||||
import TaskTreeNode from "./components/TaskTreeNode.vue";
|
||||
|
||||
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const STATUS_MAP = {
|
||||
PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成",
|
||||
REJECTED: "已驳回", ARCHIVED: "已入库",
|
||||
};
|
||||
|
||||
export default {
|
||||
components: { TaskTreeNode },
|
||||
data() {
|
||||
return {
|
||||
OVERALL_OPTIONS,
|
||||
loading: true,
|
||||
error: "",
|
||||
product: null,
|
||||
// 状态定调
|
||||
showStatusPicker: false,
|
||||
// 编辑产品
|
||||
editProductVisible: false,
|
||||
editForm: { order_no: "", external_serial: "" },
|
||||
editSaving: false,
|
||||
// 用户列表
|
||||
users: [],
|
||||
TASK_NAME_OPTIONS,
|
||||
// 发起首道工序
|
||||
createFirstVisible: false,
|
||||
firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "", autoReceive: true },
|
||||
firstSaving: false,
|
||||
// 记录/拍照
|
||||
recordPopup: { visible: false, task: null },
|
||||
recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 },
|
||||
recordSaving: false,
|
||||
isUploading: false,
|
||||
currentUser: null,
|
||||
currentUserId: "",
|
||||
currentUsername: "",
|
||||
// Picker 数据源
|
||||
processOptions: ["备货", "生产", "测试", "维修", "质检", "打包"],
|
||||
userOptions: [
|
||||
{ id: "duxingchen", name: "杜邢宸" },
|
||||
{ id: "zhangsan", name: "张三" },
|
||||
{ id: "lisi", name: "李四" },
|
||||
{ id: "wangwu", name: "王五" },
|
||||
],
|
||||
// 任务操作
|
||||
actionPopup: { visible: false, type: "", task: null },
|
||||
actionLoading: false,
|
||||
rejectReason: "",
|
||||
transferForm: { next_task_name: "", assignees: [], selectedUserId: "", warehouse: false, note: "" },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
userLabels() {
|
||||
return this.users.map(u => `${u.full_name} (${u.username})`);
|
||||
},
|
||||
canDeleteImage() {
|
||||
if (!this.recordPopup.task) return true;
|
||||
if (!this.currentUser) return true;
|
||||
return this.currentUser.username === this.recordPopup.task.assignee_id;
|
||||
},
|
||||
selectedUserName() {
|
||||
const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId);
|
||||
return u ? u.name : "";
|
||||
},
|
||||
},
|
||||
onLoad(options) {
|
||||
this.loadUsers();
|
||||
this.loadCurrentUser();
|
||||
const sn = options.serial || "";
|
||||
if (sn) this.doQuery(sn);
|
||||
},
|
||||
methods: {
|
||||
statusLabel(s) { return STATUS_MAP[s] || s; },
|
||||
statusColor(s) {
|
||||
switch (s) {
|
||||
case "PENDING": return "s-yellow";
|
||||
case "WIP": return "s-blue";
|
||||
case "COMPLETED": return "s-green";
|
||||
case "REJECTED": return "s-red";
|
||||
default: return "s-gray";
|
||||
}
|
||||
},
|
||||
countTasks(tree) { return tree ? tree.reduce((s, t) => s + 1 + this.countTasks(t.child_tasks), 0) : 0; },
|
||||
|
||||
// ---- 数据 ----
|
||||
async doQuery(sn) {
|
||||
this.loading = true; this.error = "";
|
||||
try {
|
||||
this.product = await get(`/products/scan/${sn}`);
|
||||
if (!this.product.overall_status) this.showStatusPicker = true;
|
||||
} catch (e) { this.error = e?.data?.detail || "查询失败"; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
// ---- 状态定调 ----
|
||||
async handleSetOverallStatus(status) {
|
||||
try {
|
||||
this.product = await patch(`/products/scan/${this.product.serial_number}/status`, { status });
|
||||
uni.showToast({ title: `状态已更新: ${status}`, icon: "success" });
|
||||
this.showStatusPicker = false;
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// ---- 编辑产品 ----
|
||||
openEditProduct() {
|
||||
this.editForm = {
|
||||
order_no: this.product.order_no || "",
|
||||
external_serial: this.product.external_serial || "",
|
||||
};
|
||||
this.editProductVisible = true;
|
||||
},
|
||||
async doEditProduct() {
|
||||
this.editSaving = true;
|
||||
try {
|
||||
this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() });
|
||||
uni.showToast({ title: "已保存", icon: "success" });
|
||||
this.editProductVisible = false;
|
||||
} catch {}
|
||||
finally { this.editSaving = false; }
|
||||
},
|
||||
|
||||
// ---- 用户列表 ----
|
||||
async loadUsers() {
|
||||
try {
|
||||
this.users = await get("/users/", { limit: 200 });
|
||||
} catch { /* 静默 */ }
|
||||
},
|
||||
loadCurrentUser() {
|
||||
try {
|
||||
let user = uni.getStorageSync("user");
|
||||
// ⚠️ Storage 可能返回 JSON 字符串,必须强制解析
|
||||
if (typeof user === "string" && user) {
|
||||
try { user = JSON.parse(user); } catch (e) { user = null; }
|
||||
}
|
||||
if (user && typeof user === "object") {
|
||||
this.currentUser = user;
|
||||
this.currentUserId = String(user.id || "");
|
||||
this.currentUsername = user.username || "";
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// ---- 发起首道工序 ----
|
||||
onTaskNameChange(e) {
|
||||
const idx = e.detail.value;
|
||||
this.firstForm.taskNameIdx = idx;
|
||||
this.firstForm.task_name = TASK_NAME_OPTIONS[idx];
|
||||
},
|
||||
onAssigneeChange(e) {
|
||||
const idx = e.detail.value;
|
||||
const u = this.users[idx];
|
||||
if (u) {
|
||||
this.firstForm.assigneeIdx = idx;
|
||||
this.firstForm.assignee_id = u.username;
|
||||
this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`;
|
||||
}
|
||||
},
|
||||
openCreateFirstTask() {
|
||||
this.firstForm = {
|
||||
task_name: TASK_NAME_OPTIONS[0],
|
||||
taskNameIdx: 0,
|
||||
assignee_id: this.users.length > 0 ? this.users[0].username : "",
|
||||
assigneeLabel: this.users.length > 0 ? `${this.users[0].full_name} (${this.users[0].username})` : "",
|
||||
assigneeIdx: 0,
|
||||
note: "",
|
||||
autoReceive: true,
|
||||
};
|
||||
this.createFirstVisible = true;
|
||||
},
|
||||
async doCreateFirstTask() {
|
||||
this.firstSaving = true;
|
||||
try {
|
||||
const task = await post("/tasks/", {
|
||||
product_id: this.product.id,
|
||||
task_name: this.firstForm.task_name,
|
||||
assignee_id: this.firstForm.assignee_id,
|
||||
notify_parent_on_complete: false,
|
||||
});
|
||||
// 场景B: 立即接收
|
||||
if (this.firstForm.autoReceive) {
|
||||
try {
|
||||
await post(`/tasks/${task.id}/receive`);
|
||||
} catch { /* receive 失败不影响流程 */ }
|
||||
}
|
||||
uni.showToast({
|
||||
title: this.firstForm.autoReceive ? "已创建并接收 (WIP)" : "已创建 (PENDING)",
|
||||
icon: "success",
|
||||
});
|
||||
this.createFirstVisible = false;
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {}
|
||||
finally { this.firstSaving = false; }
|
||||
},
|
||||
|
||||
// ---- 删除记录 ----
|
||||
async doDeleteRecord(record) {
|
||||
try {
|
||||
await request({ url: `/records/${record.id}`, method: "DELETE" });
|
||||
uni.showToast({ title: "记录已删除", icon: "success" });
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// ---- 记录/拍照 ----
|
||||
openRecordPopup(task) {
|
||||
this.recordPopup = { visible: true, task };
|
||||
this.recordForm = { recordId: null, remark: "", images: [], pendingCount: 0 };
|
||||
this.isUploading = false;
|
||||
},
|
||||
openEditRecord({ task, record }) {
|
||||
this.recordPopup = { visible: true, task };
|
||||
this.recordForm = {
|
||||
recordId: record.id,
|
||||
remark: record.remark || "",
|
||||
images: record.images || [],
|
||||
pendingCount: 0,
|
||||
};
|
||||
this.isUploading = false;
|
||||
},
|
||||
closeRecordPopup() { this.recordPopup = { visible: false, task: null }; },
|
||||
|
||||
// ---- 选图 → 压缩 → 上传 ----
|
||||
async handleChooseImage() {
|
||||
const maxSlots = 9 - (this.recordForm.images.length + this.recordForm.pendingCount);
|
||||
if (maxSlots <= 0) { uni.showToast({ title: "最多上传 9 张图片", icon: "none" }); return; }
|
||||
|
||||
// 第一步:选择图片(仅压缩图,禁止原图)
|
||||
const chooseRes = await new Promise((resolve, reject) => {
|
||||
uni.chooseImage({
|
||||
count: maxSlots,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["camera", "album"],
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
|
||||
|
||||
// 第二步:强制二次压缩 (quality=60,车间场景足够)
|
||||
let compressSkipCount = 0;
|
||||
const compressedPaths = [];
|
||||
for (const p of chooseRes.tempFilePaths) {
|
||||
try {
|
||||
const compressed = await new Promise((resolve, reject) => {
|
||||
uni.compressImage({
|
||||
src: p,
|
||||
quality: 60,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
compressedPaths.push(compressed.tempFilePath);
|
||||
} catch {
|
||||
// 压缩失败 → 丢弃该图片,禁止原图直传
|
||||
compressSkipCount++;
|
||||
}
|
||||
}
|
||||
if (compressSkipCount > 0) {
|
||||
uni.showToast({ title: `${compressSkipCount} 张压缩失败已丢弃`, icon: "none", duration: 1500 });
|
||||
}
|
||||
if (!compressedPaths.length) {
|
||||
uni.showToast({ title: "所有图片压缩失败,请重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 第三步:逐张上传(只传压缩后的文件)
|
||||
this.isUploading = true;
|
||||
this.recordForm.pendingCount += compressedPaths.length;
|
||||
let failCount = 0;
|
||||
for (const path of compressedPaths) {
|
||||
const url = await this.uploadFile(path);
|
||||
if (url) {
|
||||
this.recordForm.images.push(url);
|
||||
} else {
|
||||
failCount++;
|
||||
uni.showToast({ title: "单张图片上传失败", icon: "none", duration: 1500 });
|
||||
}
|
||||
this.recordForm.pendingCount--;
|
||||
}
|
||||
|
||||
this.isUploading = false;
|
||||
if (failCount > 0 && this.recordForm.images.length === 0) {
|
||||
uni.showToast({ title: `${failCount} 张全部上传失败,请重试`, icon: "none" });
|
||||
}
|
||||
},
|
||||
|
||||
uploadFile(filePath) {
|
||||
return new Promise((resolve) => {
|
||||
uni.uploadFile({
|
||||
url: "http://192.168.9.80:8011/api/v1/upload/",
|
||||
filePath,
|
||||
name: "file",
|
||||
timeout: 30000,
|
||||
success(res) {
|
||||
if (res.statusCode !== 200) { resolve(null); return; }
|
||||
try {
|
||||
const data = JSON.parse(res.data);
|
||||
if (!data || !data.url) { resolve(null); return; }
|
||||
resolve(data.url);
|
||||
} catch { resolve(null); }
|
||||
},
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeRecordImage(i) { this.recordForm.images.splice(i, 1); },
|
||||
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images, current: i }); },
|
||||
|
||||
// ---- 保存记录 ----
|
||||
async doSaveRecord() {
|
||||
if (this.isUploading) { uni.showToast({ title: "图片上传中,请稍候", icon: "none" }); return; }
|
||||
if (this.recordForm.pendingCount > 0) { uni.showToast({ title: "有图片未上传成功,请删除或重试", icon: "none" }); return; }
|
||||
|
||||
this.recordSaving = true;
|
||||
try {
|
||||
const payload = { remark: this.recordForm.remark.trim(), images: this.recordForm.images };
|
||||
if (this.recordForm.recordId) {
|
||||
await put(`/records/${this.recordForm.recordId}`, payload);
|
||||
} else {
|
||||
await patch(`/tasks/${this.recordPopup.task.id}/records`, payload);
|
||||
}
|
||||
uni.showToast({ title: this.recordForm.recordId ? "记录已更新" : "记录已保存", icon: "success" });
|
||||
this.closeRecordPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {}
|
||||
finally { this.recordSaving = false; }
|
||||
},
|
||||
|
||||
// ---- 任务操作 ----
|
||||
handleTaskAction({ task, type, record }) {
|
||||
if (type === "record") { this.openRecordPopup(task); return; }
|
||||
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
|
||||
this.actionPopup = { visible: true, type, task };
|
||||
this.rejectReason = "";
|
||||
this.transferForm = { next_task_name: "", assignees: [], selectedUserId: "", warehouse: false, note: "" };
|
||||
},
|
||||
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
|
||||
|
||||
async doReceive() {
|
||||
this.actionLoading = true;
|
||||
try {
|
||||
await post(`/tasks/${this.actionPopup.task.id}/receive`);
|
||||
uni.showToast({ title: "已接收", icon: "success" });
|
||||
this.closeActionPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {} finally { this.actionLoading = false; }
|
||||
},
|
||||
async doReject() {
|
||||
this.actionLoading = true;
|
||||
try {
|
||||
await post(`/tasks/${this.actionPopup.task.id}/reject`, { reason: this.rejectReason.trim() });
|
||||
uni.showToast({ title: "已驳回,返工任务已创建", icon: "success" });
|
||||
this.closeActionPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {} finally { this.actionLoading = false; }
|
||||
},
|
||||
onProcessChange(e) {
|
||||
this.transferForm.next_task_name = this.processOptions[e.detail.value];
|
||||
},
|
||||
onUserChange(e) {
|
||||
const user = this.userOptions[e.detail.value];
|
||||
if (user) {
|
||||
this.transferForm.selectedUserId = user.id;
|
||||
this.transferForm.assignees = [user.id];
|
||||
}
|
||||
},
|
||||
async doTransfer() {
|
||||
this.actionLoading = true;
|
||||
const finalAssignees = [...this.transferForm.assignees];
|
||||
if (this.transferForm.warehouse) finalAssignees.push("virtual_warehouse");
|
||||
try {
|
||||
await post(`/tasks/${this.actionPopup.task.id}/transfer`, {
|
||||
next_assignees: finalAssignees,
|
||||
next_task_name: this.transferForm.next_task_name.trim(),
|
||||
note: this.transferForm.note.trim() || undefined,
|
||||
});
|
||||
uni.showToast({ title: "转交成功", icon: "success" });
|
||||
this.closeActionPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {} finally { this.actionLoading = false; }
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 100px; }
|
||||
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
|
||||
|
||||
/* 宏观状态 */
|
||||
.overall-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px;
|
||||
background: #fff; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.overall-label { font-size: 13px; color: #6b7280; }
|
||||
.overall-val { font-size: 15px; font-weight: 700; color: #2563eb; flex: 1; }
|
||||
.overall-empty { color: #ef4444; }
|
||||
.overall-arrow { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
/* 卡片 */
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.card-header-right { display: flex; align-items: center; gap: 8px; }
|
||||
.card-title { font-size: 15px; font-weight: 700; }
|
||||
.edit-btn { font-size: 18px; padding: 2px 6px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.label { font-size: 12px; color: #9ca3af; }
|
||||
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
|
||||
.sn { font-family: monospace; }
|
||||
.warehouse { color: #7c3aed; }
|
||||
.badge { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
|
||||
.s-yellow .badge, .s-yellow { color: #b45309; }
|
||||
.s-blue .badge, .s-blue { color: #1d4ed8; }
|
||||
.s-green .badge, .s-green { color: #15803d; }
|
||||
.s-red .badge, .s-red { color: #be123c; }
|
||||
.s-gray .badge, .s-gray { color: #6b7280; }
|
||||
.task-count { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
/* 0任务 */
|
||||
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
|
||||
.zero-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.zero-text { font-size: 14px; color: #9ca3af; margin-bottom: 16px; }
|
||||
.btn-start { width: 220px; height: 44px; background: linear-gradient(135deg, #2563EB, #3B82F6);
|
||||
border: none; border-radius: 12px; color: #fff; font-size: 15px; font-weight: 700; line-height: 44px; box-shadow: 0 4px 12px rgba(37,99,235,0.3); }
|
||||
|
||||
/* 遮罩 + 底部面板 */
|
||||
.overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45);
|
||||
display: flex; align-items: flex-end; justify-content: center; }
|
||||
.sheet { width: 100%; max-width: 480px; background: #fff; border-radius: 20px 20px 0 0; padding: 20px 16px 32px; }
|
||||
.sheet-title { font-size: 17px; font-weight: 700; display: block; text-align: center; }
|
||||
.sheet-hint { font-size: 13px; color: #9ca3af; display: block; text-align: center; margin: 6px 0 16px; }
|
||||
.sheet-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.sheet-opt { padding: 14px 8px; border-radius: 12px; text-align: center; font-size: 15px; font-weight: 600;
|
||||
background: #f3f4f6; color: #374151; border: 2px solid transparent; }
|
||||
.sheet-opt-active { background: #dbeafe; color: #2563eb; border-color: #2563eb; }
|
||||
.sheet-close { margin-top: 14px; height: 40px; background: #f3f4f6; border: none; border-radius: 10px; font-size: 14px; color: #6b7280; line-height: 40px; }
|
||||
|
||||
/* 通用弹窗 */
|
||||
.popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0;
|
||||
padding: 20px 16px 32px; max-height: 80vh; overflow-y: auto; }
|
||||
.popup-title { font-size: 16px; font-weight: 700; display: block; text-align: center; margin-bottom: 12px; }
|
||||
.popup-task { font-size: 14px; font-weight: 600; color: #2563eb; text-align: center; margin-bottom: 4px; }
|
||||
.popup-hint { font-size: 12px; color: #9ca3af; display: block; text-align: center; }
|
||||
.popup-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; margin: 10px 0; box-sizing: border-box; }
|
||||
.popup-input { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-size: 14px; margin: 8px 0; box-sizing: border-box; }
|
||||
.flex-1 { flex: 1; margin: 0; }
|
||||
.popup-btns { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.btn-cancel { flex: 1; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; color: #6b7280; font-size: 14px; line-height: 42px; }
|
||||
.btn-primary { flex: 1; height: 42px; border: none; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
|
||||
.btn-primary[disabled] { opacity: 0.5; }
|
||||
.btn-danger { flex: 1; height: 42px; border: none; border-radius: 10px; background: #dc2626; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
|
||||
.btn-danger[disabled] { opacity: 0.5; }
|
||||
.btn-sm { height: 42px; width: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f3f4f6; font-size: 18px; line-height: 42px; text-align: center; padding: 0; }
|
||||
.popup-assignee-area { margin: 8px 0; }
|
||||
.popup-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||
.popup-tag { padding: 4px 10px; border-radius: 20px; background: #dbeafe; color: #2563eb; font-size: 12px; font-weight: 600; }
|
||||
.popup-tag-wh { background: #ede9fe; color: #7c3aed; }
|
||||
.popup-add-row { display: flex; gap: 6px; }
|
||||
.wh-label { font-size: 13px; display: flex; align-items: center; gap: 4px; margin-top: 6px; color: #6b7280; }
|
||||
.preview-hint { font-size: 12px; background: #f0fdf4; color: #16a34a; padding: 8px 10px; border-radius: 8px; margin: 6px 0; }
|
||||
|
||||
/* Picker & 表单 */
|
||||
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
|
||||
.required { color: #ef4444; }
|
||||
.picker-box { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px;
|
||||
padding: 0 12px; font-size: 14px; color: #1f2937; line-height: 42px; box-sizing: border-box; background: #fff; }
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; padding: 8px 0; }
|
||||
.switch-label { font-size: 15px; font-weight: 600; color: #2563eb; }
|
||||
.switch-hint { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||||
|
||||
/* 图片上传 — 九宫格 */
|
||||
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }
|
||||
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
|
||||
.img-thumb { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
|
||||
.img-cell-loading { display: flex; align-items: center; justify-content: center;
|
||||
background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.img-loading-text { font-size: 36rpx; }
|
||||
.img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx;
|
||||
background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx;
|
||||
text-align: center; line-height: 40rpx; z-index: 2; }
|
||||
.btn-upload { width: 100%; height: 42px; border: 1px dashed #d1d5db; border-radius: 10px;
|
||||
background: #f9fafb; color: #6b7280; font-size: 14px; line-height: 42px; margin: 8px 0; }
|
||||
.btn-upload[disabled] { opacity: 0.5; }
|
||||
|
||||
/* Picker 选择器 */
|
||||
.form-item { margin: 10px 0; }
|
||||
.form-label { font-size: 14px; font-weight: 600; color: #374151; display: block; margin-bottom: 4px; }
|
||||
.required { color: #ef4444; }
|
||||
.picker-value {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; height: 42px; padding: 0 12px;
|
||||
border: 1px solid #e5e7eb; border-radius: 10px;
|
||||
background: #f9fafb; font-size: 14px; box-sizing: border-box;
|
||||
}
|
||||
.picker-placeholder { color: #9ca3af; }
|
||||
.picker-arrow { font-size: 12px; color: #9ca3af; margin-left: 8px; }
|
||||
</style>
|
||||
98
track-uniapp/src/pages/scan/index.vue
Normal file
98
track-uniapp/src/pages/scan/index.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 扫码大按钮 -->
|
||||
<view class="scan-btn" @tap="handleScanCode">
|
||||
<text class="scan-icon">📷</text>
|
||||
<text class="scan-text">点击扫码</text>
|
||||
<text class="scan-hint">扫描二维码 / 条码查询产品</text>
|
||||
</view>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<view class="manual-input">
|
||||
<input v-model="serialNumber" class="input" type="text" maxlength="16"
|
||||
placeholder="手动输入16位序列号" @confirm="handleSearch" />
|
||||
<button class="search-btn" @tap="handleSearch" :disabled="loading">
|
||||
{{ loading ? '查询中' : '查询' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 最近扫描 -->
|
||||
<view v-if="lastScanned" class="last-scan">最近扫描: <text class="sn-text">{{ lastScanned }}</text></view>
|
||||
<view v-if="loading" class="loading">查询中...</view>
|
||||
<view v-if="error" class="error-box">{{ error }}</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-if="!loading && !error" class="empty">
|
||||
<text class="empty-icon">📱</text>
|
||||
<text class="empty-text">扫码或手动输入序列号查询产品进度</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get } from "../../utils/request";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
serialNumber: "",
|
||||
lastScanned: "",
|
||||
loading: false,
|
||||
error: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async doQuery(sn) {
|
||||
if (!sn || sn.length < 8) { this.error = "序列号至少需要 8 位"; return; }
|
||||
this.serialNumber = sn;
|
||||
this.lastScanned = sn;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
await get(`/products/scan/${sn}`);
|
||||
uni.navigateTo({ url: `/pages/scan/detail?serial=${sn}` });
|
||||
} catch (e) {
|
||||
this.error = e?.data?.detail || "未找到该产品";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
handleSearch() { this.doQuery(this.serialNumber.trim()); },
|
||||
handleScanCode() {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true, scanType: ["qrCode", "barCode"],
|
||||
success: (res) => {
|
||||
const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
|
||||
this.doQuery(sn);
|
||||
},
|
||||
fail: (err) => {
|
||||
if (!err.errMsg || !err.errMsg.includes("cancel")) {
|
||||
uni.showToast({ title: "扫码失败,请重试", icon: "none" });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 40px 16px 16px; }
|
||||
.scan-btn { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: 180px; background: linear-gradient(135deg, #2563EB, #3B82F6);
|
||||
border-radius: 16px; color: #fff; box-shadow: 0 4px 16px rgba(37,99,235,0.3); }
|
||||
.scan-icon { font-size: 52px; margin-bottom: 8px; }
|
||||
.scan-text { font-size: 20px; font-weight: 700; }
|
||||
.scan-hint { font-size: 13px; opacity: 0.8; margin-top: 4px; }
|
||||
.manual-input { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.input { flex: 1; height: 44px; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 14px; background: #fff; }
|
||||
.search-btn { height: 44px; padding: 0 20px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; line-height: 44px; }
|
||||
.search-btn[disabled] { opacity: 0.6; }
|
||||
.last-scan { font-size: 12px; color: #9ca3af; margin-top: 16px; }
|
||||
.sn-text { font-family: monospace; color: #4b5563; }
|
||||
.loading { text-align: center; padding: 24px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; margin-top: 12px; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 40px; color: #9ca3af; }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.empty-text { font-size: 14px; }
|
||||
</style>
|
||||
25
track-uniapp/src/pages/tasks/index.vue
Normal file
25
track-uniapp/src/pages/tasks/index.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">我的任务</text>
|
||||
<text class="subtitle">待处理和进行中的任务</text>
|
||||
</view>
|
||||
<view class="empty">
|
||||
<text class="empty-icon">📋</text>
|
||||
<text class="empty-text">暂无待办任务</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
</style>
|
||||
67
track-uniapp/src/utils/request.js
Normal file
67
track-uniapp/src/utils/request.js
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* uni.request 封装 — 统一的 HTTP 客户端
|
||||
* 自动携带 Token、401 跳转登录
|
||||
*/
|
||||
|
||||
const BASE_URL = "http://192.168.9.80:8011/api/v1";
|
||||
|
||||
export default function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
|
||||
const token = uni.getStorageSync("token") || "";
|
||||
|
||||
uni.request({
|
||||
url,
|
||||
method: options.method || "GET",
|
||||
data: options.data || {},
|
||||
header: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.header || {}),
|
||||
},
|
||||
timeout: 15000,
|
||||
success(res) {
|
||||
const code = res.statusCode;
|
||||
if (code >= 200 && code < 300) {
|
||||
resolve(res.data);
|
||||
} else if (code === 401) {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("user");
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
|
||||
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
|
||||
reject(res);
|
||||
} else if (code === 400) {
|
||||
uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
reject(res);
|
||||
} else {
|
||||
uni.showToast({ title: `请求失败 (${code})`, icon: "none" });
|
||||
reject(res);
|
||||
}
|
||||
},
|
||||
fail() {
|
||||
uni.showToast({ title: "网络连接失败", icon: "none" });
|
||||
reject(new Error("network"));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function get(url, params = {}) {
|
||||
const query = Object.entries(params)
|
||||
.filter(([, v]) => v != null && v !== "")
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
|
||||
}
|
||||
|
||||
export function post(url, data = {}) {
|
||||
return request({ url, method: "POST", data });
|
||||
}
|
||||
|
||||
export function patch(url, data = {}) {
|
||||
return request({ url, method: "PATCH", data });
|
||||
}
|
||||
|
||||
export function put(url, data = {}) {
|
||||
return request({ url, method: "PUT", data });
|
||||
}
|
||||
29
track-uniapp/sync-watch.sh
Normal file
29
track-uniapp/sync-watch.sh
Normal file
@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# uni-app 双向同步脚本
|
||||
# WSL ←→ Windows G 盘,两边改代码互相跟随
|
||||
# 用法: bash sync-watch.sh 停止: Ctrl+C
|
||||
# ============================================================
|
||||
|
||||
SRC="/home/yueli/track/track-uniapp"
|
||||
DST="/mnt/g/Track/track-app/track"
|
||||
|
||||
EXCLUDES="--exclude node_modules --exclude .git --exclude dist --exclude unpackage --exclude .hbuilderx --exclude package.json --exclude package-lock.json --exclude vite.config.ts --exclude uni.scss --exclude uni.promisify.adaptor.js"
|
||||
|
||||
do_sync() {
|
||||
rsync -av $EXCLUDES "$SRC/" "$DST/" 2>/dev/null
|
||||
sleep 0.5
|
||||
rsync -av $EXCLUDES "$DST/" "$SRC/" 2>/dev/null
|
||||
}
|
||||
|
||||
echo "🔁 双向同步 $SRC ↔ $DST"
|
||||
echo " 按 Ctrl+C 停止"
|
||||
do_sync
|
||||
echo "✅ 首次同步完成,开始监听..."
|
||||
|
||||
inotifywait -m -r -e modify,create,delete,move \
|
||||
--exclude 'node_modules|.git|dist|unpackage|.hbuilderx' \
|
||||
"$SRC" 2>/dev/null | while read -r dir action file; do
|
||||
do_sync
|
||||
echo " ↻ $(date +%H:%M:%S) 已同步"
|
||||
done
|
||||
59
track-uniapp/utils/request.js
Normal file
59
track-uniapp/utils/request.js
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* uni.request 封装 — 统一的 HTTP 客户端
|
||||
* 自动携带 Token、401 跳转登录
|
||||
*/
|
||||
|
||||
const BASE_URL = "http://192.168.9.80:8011/api/v1";
|
||||
|
||||
export default function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
|
||||
const token = uni.getStorageSync("token") || "";
|
||||
|
||||
uni.request({
|
||||
url,
|
||||
method: options.method || "GET",
|
||||
data: options.data || {},
|
||||
header: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.header || {}),
|
||||
},
|
||||
timeout: 15000,
|
||||
success(res) {
|
||||
const code = res.statusCode;
|
||||
if (code >= 200 && code < 300) {
|
||||
resolve(res.data);
|
||||
} else if (code === 401) {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("user");
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
|
||||
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
|
||||
reject(res);
|
||||
} else if (code === 400) {
|
||||
uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
reject(res);
|
||||
} else {
|
||||
uni.showToast({ title: `请求失败 (${code})`, icon: "none" });
|
||||
reject(res);
|
||||
}
|
||||
},
|
||||
fail() {
|
||||
uni.showToast({ title: "网络连接失败", icon: "none" });
|
||||
reject(new Error("network"));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function get(url, params = {}) {
|
||||
const query = Object.entries(params)
|
||||
.filter(([, v]) => v != null && v !== "")
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
|
||||
}
|
||||
|
||||
export function post(url, data = {}) {
|
||||
return request({ url, method: "POST", data });
|
||||
}
|
||||
12
track-uniapp/vite.config.ts
Normal file
12
track-uniapp/vite.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import uni from "@dcloudio/vite-plugin-uni";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni(), basicSsl()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 8020,
|
||||
https: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user