纳入 track-uniapp 前端项目 (扫码/任务树/记录/转交Picker)

This commit is contained in:
2026-08-05 15:01:46 +08:00
parent eaaeb38d0a
commit 5e53cd7f79
28 changed files with 12898 additions and 1 deletions

1
.gitignore vendored
View File

@ -45,6 +45,5 @@ backend/data/
*.tmp
# ===== 排除独立项目/个人文档 =====
track-uniapp/
track1.0.md
PROJECT_STATUS.md

6
track-uniapp/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
unpackage/
.env
*.log
.DS_Store
Thumbs.db

View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
import App from "./App.vue";
import { createSSRApp } from "vue";
export function createApp() {
const app = createSSRApp(App);
return { app };
}

View 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

File diff suppressed because it is too large Load Diff

73
track-uniapp/pages.json Normal file
View 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": "我的"
}
]
}
}

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View File

@ -0,0 +1,7 @@
import { createSSRApp } from "vue";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
return { app };
}

View 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": "生产流转"
}
}

View 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": "我的"
}
]
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 });
}

View 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

View 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 });
}

View 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,
},
});