修复生产统计与维修记录用户数据
This commit is contained in:
@@ -1,7 +0,0 @@
|
|||||||
# 项目规则
|
|
||||||
|
|
||||||
## 本地项目与版本控制
|
|
||||||
|
|
||||||
- 本项目为本地项目,不使用 Git 进行状态检查、差异比较、提交或任何其他操作。
|
|
||||||
- 后续回答、验证命令和开发说明中不出现 Git 或 Git 命令。
|
|
||||||
- 如需确认文件改动,以实际文件内容、语法检查、自动化测试或运行时验证为准。
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
---
|
---
|
||||||
name: frontend-app-common
|
name: frontend-app-common
|
||||||
description: scanapp 移动端公共开发规范,基于 uni-app、Vue 和 uView Pro,覆盖页面组织、路由、组件、扫码交互、视觉字号和交付检查
|
description: app 移动端公共开发规范,基于 uni-app、Vue 和 uView Pro,覆盖页面组织、路由、组件、扫码交互、视觉字号和交付检查
|
||||||
globs:
|
globs:
|
||||||
- "frontend/scanapp/**/*.vue"
|
- "frontend/app/**/*.vue"
|
||||||
- "frontend/scanapp/**/*.js"
|
- "frontend/app/**/*.js"
|
||||||
- "frontend/scanapp/pages.json"
|
- "frontend/app/pages.json"
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术基线
|
## 技术基线
|
||||||
|
|
||||||
当前 APP 位于 `frontend/scanapp/`,使用以下技术约定:
|
当前 APP 位于 `frontend/app/`,使用以下技术约定:
|
||||||
|
|
||||||
- uni-app 页面运行模式。
|
- uni-app 页面运行模式。
|
||||||
- 页面采用 Vue 单文件组件(`.vue`)。
|
- 页面采用 Vue 单文件组件(`.vue`)。
|
||||||
@@ -22,7 +22,7 @@ globs:
|
|||||||
## 目录与页面组织
|
## 目录与页面组织
|
||||||
|
|
||||||
```text
|
```text
|
||||||
frontend/scanapp/
|
frontend/app/
|
||||||
├── App.vue
|
├── App.vue
|
||||||
├── main.js
|
├── main.js
|
||||||
├── pages.json
|
├── pages.json
|
||||||
@@ -154,9 +154,9 @@ uni.scanCode({
|
|||||||
|
|
||||||
### 接口封装
|
### 接口封装
|
||||||
|
|
||||||
- scanapp 对接后端时统一通过 `frontend/scanapp/utils/request.js` 发起 `uni.request`,页面中不要直接调用 `uni.request`。
|
- APP 对接后端时统一通过 `frontend/app/utils/request.js` 发起 `uni.request`,页面中不要直接调用 `uni.request`。
|
||||||
- 请求配置集中放在 `frontend/scanapp/config/index.js`,当前生产插件 APP 业务端默认 `baseURL` 为 `/appapi`;主项目系统 API(例如 `/file/image` 上传)实际注册在 `/api`,调用时必须显式传入 `baseURL: config.apiBaseURL`,不能沿用默认基址。
|
- 请求配置集中放在 `frontend/app/config/index.js`,当前生产插件 APP 业务端默认 `baseURL` 为 `/appapi`;主项目系统 API(例如 `/file/image` 上传)实际注册在 `/api`,调用时必须显式传入 `baseURL: config.apiBaseURL`,不能沿用默认基址。
|
||||||
- 业务接口按模块放入 `frontend/scanapp/api/<module>/`,例如生产扫码接口放在 `api/produce/data-entry.js`。
|
- 业务接口按模块放入 `frontend/app/api/<module>/`,例如生产扫码接口放在 `api/produce/data-entry.js`。
|
||||||
- API 函数命名按页面语义表达,例如 `getProcessOptions`、`submitOrderRecord`、`getOrderStatistics`,页面只调用这些函数。
|
- API 函数命名按页面语义表达,例如 `getProcessOptions`、`submitOrderRecord`、`getOrderStatistics`,页面只调用这些函数。
|
||||||
- 业务表单中的图片统一采用“本地预览、提交时上传”:选图后仅保存 `tempFilePaths` 用于立即预览;提交时调用 `@/api/upload.js` 的 `uploadImage(filePath, subDir)`,将返回的服务器 URL 放入业务请求。上传失败时中止提交并保留本地图片,便于用户重试。
|
- 业务表单中的图片统一采用“本地预览、提交时上传”:选图后仅保存 `tempFilePaths` 用于立即预览;提交时调用 `@/api/upload.js` 的 `uploadImage(filePath, subDir)`,将返回的服务器 URL 放入业务请求。上传失败时中止提交并保留本地图片,便于用户重试。
|
||||||
- 图片上传使用 `/api/file/image` 和 `config.apiBaseURL`;公共 `normalizeImageUrl()` 会将后端本地存储返回的 `127.0.0.1` / `localhost` URL 改写为配置的局域网后端地址,避免 Android 真机将回环地址解析为手机自身。历史记录中的服务器图片地址也必须经过该方法规范化。
|
- 图片上传使用 `/api/file/image` 和 `config.apiBaseURL`;公共 `normalizeImageUrl()` 会将后端本地存储返回的 `127.0.0.1` / `localhost` URL 改写为配置的局域网后端地址,避免 Android 真机将回环地址解析为手机自身。历史记录中的服务器图片地址也必须经过该方法规范化。
|
||||||
@@ -235,7 +235,7 @@ uni.scanCode({
|
|||||||
|
|
||||||
## 开发检查清单
|
## 开发检查清单
|
||||||
|
|
||||||
- [ ] 页面文件位于 `frontend/scanapp/pages/` 合适目录。
|
- [ ] 页面文件位于 `frontend/app/pages/` 合适目录。
|
||||||
- [ ] 新页面已注册到 `pages.json`。
|
- [ ] 新页面已注册到 `pages.json`。
|
||||||
- [ ] 导航路径与 `pages.json` 完全一致。
|
- [ ] 导航路径与 `pages.json` 完全一致。
|
||||||
- [ ] 已复用 uView Pro 组件和现有 `uni.*` 能力。
|
- [ ] 已复用 uView Pro 组件和现有 `uni.*` 能力。
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use plugin\produce\app\model\ExceptionSetting;
|
|||||||
use plugin\produce\app\model\HardwareFaultDescription;
|
use plugin\produce\app\model\HardwareFaultDescription;
|
||||||
use plugin\produce\app\model\Material;
|
use plugin\produce\app\model\Material;
|
||||||
use plugin\produce\app\model\MaterialCategory;
|
use plugin\produce\app\model\MaterialCategory;
|
||||||
|
use plugin\produce\app\model\MemberNotification;
|
||||||
use plugin\produce\app\model\Process;
|
use plugin\produce\app\model\Process;
|
||||||
use plugin\produce\app\model\ProcessStep;
|
use plugin\produce\app\model\ProcessStep;
|
||||||
use plugin\produce\app\model\ProductRecord;
|
use plugin\produce\app\model\ProductRecord;
|
||||||
@@ -367,6 +368,8 @@ final class DataEntryController extends Base
|
|||||||
'today_total' => $todayTotal,
|
'today_total' => $todayTotal,
|
||||||
'success_total' => $successTotal,
|
'success_total' => $successTotal,
|
||||||
'failed_total' => $failedTotal,
|
'failed_total' => $failedTotal,
|
||||||
|
'process_breakdown' => $this->buildTodayProcessStepBreakdown(clone $query),
|
||||||
|
'daily_trend' => $this->buildDailyTrend(clone $query, 'scan_time'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,9 +425,166 @@ final class DataEntryController extends Base
|
|||||||
'today_total' => $todayTotal,
|
'today_total' => $todayTotal,
|
||||||
'success_total' => $successTotal,
|
'success_total' => $successTotal,
|
||||||
'failed_total' => $failedTotal,
|
'failed_total' => $failedTotal,
|
||||||
|
'process_breakdown' => $this->buildTodayProcessStepBreakdown(clone $query),
|
||||||
|
'daily_trend' => $this->buildDailyTrend(clone $query, 'created_at'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Get(path: '/produce/dashboard/today', summary: '获取首页今日生产总览', tags: ['produce-web'])]
|
||||||
|
#[DataResponse(description: '获取成功')]
|
||||||
|
public function todayDashboard(): Response
|
||||||
|
{
|
||||||
|
$member = $this->currentMember->user();
|
||||||
|
if (!$member) {
|
||||||
|
return Json::fail('请先登录', [], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||||
|
$tomorrowStart = $todayStart + 86400;
|
||||||
|
$monthStart = strtotime(date('Y-m-01 00:00:00'));
|
||||||
|
$nextMonthStart = strtotime('+1 month', $monthStart);
|
||||||
|
$userCode = (string) $member->username;
|
||||||
|
$orders = ScanData::query()
|
||||||
|
->where('user_code', $userCode)
|
||||||
|
->whereBetween('scan_time', [$todayStart, $tomorrowStart - 1]);
|
||||||
|
$products = ProductRecord::query()
|
||||||
|
->where('user_code', $userCode)
|
||||||
|
->whereBetween('created_at', [$todayStart, $tomorrowStart - 1]);
|
||||||
|
$exceptions = ExceptionRecord::query()
|
||||||
|
->where('user_code', $userCode)
|
||||||
|
->whereBetween('reported_at', [$todayStart, $tomorrowStart - 1]);
|
||||||
|
$operatorNames = array_values(array_unique(array_filter([
|
||||||
|
trim((string) ($member->nickname ?? '')),
|
||||||
|
$userCode,
|
||||||
|
])));
|
||||||
|
$repairs = RepairRecord::query()
|
||||||
|
->where(function ($query) use ($operatorNames, $userCode): void {
|
||||||
|
$query->whereIn('operator', $operatorNames)
|
||||||
|
->orWhereHas('exception', static fn ($exceptionQuery) => $exceptionQuery->where('user_code', $userCode));
|
||||||
|
})
|
||||||
|
->whereBetween('repaired_at', [$todayStart, $tomorrowStart - 1]);
|
||||||
|
$exceptionTotal = (int) (clone $exceptions)->count();
|
||||||
|
$responsibilityData = $this->buildMonthlyResponsibilityOrders($monthStart, $nextMonthStart, $userCode);
|
||||||
|
return Json::success('获取成功', [
|
||||||
|
'summary' => [
|
||||||
|
'order_scan' => (int) (clone $orders)->sum('quantity'),
|
||||||
|
'product_scan' => (int) (clone $products)->sum('quantity'),
|
||||||
|
'exception' => $exceptionTotal,
|
||||||
|
'repair' => (int) (clone $repairs)->count(),
|
||||||
|
],
|
||||||
|
'order_breakdown' => $this->buildTodayProcessStepBreakdown(clone $orders),
|
||||||
|
'repair_breakdown' => $this->buildTodayProcessStepBreakdown(clone $repairs),
|
||||||
|
'product_breakdown' => $this->buildTodayProcessStepBreakdown(clone $products),
|
||||||
|
'responsibility_order_total' => $responsibilityData['total'],
|
||||||
|
'responsibility_orders' => $responsibilityData['items'],
|
||||||
|
'trend' => $this->buildTodayHourlyTrend($orders, $products, $todayStart, $tomorrowStart),
|
||||||
|
'exceptions' => [
|
||||||
|
'pending' => (int) (clone $exceptions)->where('status', 0)->count(),
|
||||||
|
'processing' => (int) (clone $exceptions)->where('status', 1)->count(),
|
||||||
|
'completed' => (int) (clone $exceptions)->where('status', 2)->count(),
|
||||||
|
'total' => $exceptionTotal,
|
||||||
|
],
|
||||||
|
'updated_at' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计本月当前会员承担责任的不良订单,并返回最近五条。 */
|
||||||
|
private function buildMonthlyResponsibilityOrders(int $monthStart, int $nextMonthStart, string $userCode): array
|
||||||
|
{
|
||||||
|
$repairs = RepairRecord::query()
|
||||||
|
->where('repair_result', 'operation_defect')
|
||||||
|
->whereBetween('repaired_at', [$monthStart, $nextMonthStart - 1])
|
||||||
|
->orderByDesc('repaired_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get(['id', 'order_no', 'exception_reason', 'repaired_at', 'responsible_operators']);
|
||||||
|
$items = [];
|
||||||
|
foreach ($repairs as $repair) {
|
||||||
|
$snapshots = is_array($repair->responsible_operators) ? $repair->responsible_operators : [];
|
||||||
|
$isResponsible = collect($snapshots)->contains(static fn (array $snapshot): bool => trim((string) ($snapshot['user_code'] ?? '')) === $userCode);
|
||||||
|
if (!$isResponsible) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'id' => (string) $repair->id,
|
||||||
|
'order_no' => (string) $repair->order_no,
|
||||||
|
'exception_reason' => (string) $repair->exception_reason,
|
||||||
|
'quantity' => 1,
|
||||||
|
'repaired_at' => (int) $repair->repaired_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => count($items),
|
||||||
|
'items' => array_slice($items, 0, 5),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计最近六个整点时段的扫码数量。 */
|
||||||
|
private function buildTodayHourlyTrend($orders, $products, int $todayStart, int $tomorrowStart): array
|
||||||
|
{
|
||||||
|
$trend = [];
|
||||||
|
$currentHour = strtotime(date('Y-m-d H:00:00'));
|
||||||
|
for ($offset = 5; $offset >= 0; $offset--) {
|
||||||
|
$hourStart = $currentHour - ($offset * 3600);
|
||||||
|
$rangeStart = max($todayStart, $hourStart);
|
||||||
|
$rangeEnd = min($tomorrowStart - 1, $hourStart + 3599);
|
||||||
|
$orderCount = (int) (clone $orders)->whereBetween('scan_time', [$rangeStart, $rangeEnd])->sum('quantity');
|
||||||
|
$productCount = (int) (clone $products)->whereBetween('created_at', [$rangeStart, $rangeEnd])->sum('quantity');
|
||||||
|
$trend[] = ['label' => date('H:i', $hourStart), 'value' => $orderCount + $productCount];
|
||||||
|
}
|
||||||
|
return $trend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总今日记录的工序与步骤数量。 */
|
||||||
|
private function buildTodayProcessStepBreakdown($query): array
|
||||||
|
{
|
||||||
|
$isRepairRecord = $query->getModel()->getTable() === 'produce_repair_record';
|
||||||
|
$quantityExpression = $isRepairRecord ? 'COUNT(*)' : 'SUM(quantity)';
|
||||||
|
$snapshotColumns = $isRepairRecord ? ', MAX(process_name) as record_process_name, MAX(step_name) as record_step_name' : '';
|
||||||
|
$rows = $query
|
||||||
|
->selectRaw("process_id, step_id{$snapshotColumns}, {$quantityExpression} as quantity")
|
||||||
|
->groupBy('process_id', 'step_id')
|
||||||
|
->orderByDesc('quantity')
|
||||||
|
->get();
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$processNames = Process::query()
|
||||||
|
->whereIn('id', $rows->pluck('process_id')->filter()->all())
|
||||||
|
->pluck('process_name', 'id');
|
||||||
|
$stepNames = ProcessStep::query()
|
||||||
|
->whereIn('id', $rows->pluck('step_id')->filter()->all())
|
||||||
|
->pluck('step_name', 'id');
|
||||||
|
|
||||||
|
return $rows->map(static function ($row) use ($processNames, $stepNames): array {
|
||||||
|
$processId = (string) ($row->process_id ?? '');
|
||||||
|
$stepId = (string) ($row->step_id ?? '');
|
||||||
|
return [
|
||||||
|
'process_id' => $processId,
|
||||||
|
'step_id' => $stepId,
|
||||||
|
'process_name' => (string) (($row->record_process_name ?? '') ?: $processNames->get($row->process_id) ?: '未记录工序'),
|
||||||
|
'step_name' => (string) (($row->record_step_name ?? '') ?: $stepNames->get($row->step_id) ?: '未记录步骤'),
|
||||||
|
'quantity' => (int) $row->quantity,
|
||||||
|
];
|
||||||
|
})->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总查询区间内每日数量。 */
|
||||||
|
private function buildDailyTrend($query, string $dateColumn): array
|
||||||
|
{
|
||||||
|
$rows = $query
|
||||||
|
->selectRaw("FROM_UNIXTIME({$dateColumn}, '%Y-%m-%d') as day, SUM(quantity) as quantity")
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
return $rows->map(static fn ($row): array => [
|
||||||
|
'date' => (string) $row->day,
|
||||||
|
'quantity' => (int) $row->quantity,
|
||||||
|
])->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
#[OA\Get(path: '/produce/product-record/user-code-stats', summary: '前台整机用户统计', tags: ['produce-web'])]
|
#[OA\Get(path: '/produce/product-record/user-code-stats', summary: '前台整机用户统计', tags: ['produce-web'])]
|
||||||
#[DataResponse(description: '获取成功')]
|
#[DataResponse(description: '获取成功')]
|
||||||
#[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')]
|
#[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')]
|
||||||
@@ -860,13 +1020,24 @@ final class DataEntryController extends Base
|
|||||||
public function repairStatistics(Request $request): Response
|
public function repairStatistics(Request $request): Response
|
||||||
{
|
{
|
||||||
$query = RepairRecord::query();
|
$query = RepairRecord::query();
|
||||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
$userCode = trim((string) $request->input('user_code', ''));
|
||||||
$todayEnd = $todayStart + 86400;
|
$startAt = $this->parseQueryDate($request->input('start_date')) ?: strtotime(date('Y-m-d 00:00:00'));
|
||||||
|
$endAt = $this->parseQueryDate($request->input('end_date'), true) ?: strtotime(date('Y-m-d 23:59:59'));
|
||||||
|
if ($userCode !== '') {
|
||||||
|
$member = Member::query()->where('username', $userCode)->first();
|
||||||
|
$operators = array_values(array_unique(array_filter([$userCode, (string) ($member?->nickname ?? '')])));
|
||||||
|
$query->where(function ($builder) use ($operators, $userCode): void {
|
||||||
|
$builder->whereIn('operator', $operators)
|
||||||
|
->orWhereHas('exception', static fn ($exceptionQuery) => $exceptionQuery->where('user_code', $userCode));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$query->whereBetween('repaired_at', [$startAt, $endAt]);
|
||||||
|
|
||||||
return Json::success('获取成功', [
|
return Json::success('获取成功', [
|
||||||
'pending' => ExceptionRecord::query()->where('status', 0)->count(),
|
'pending' => ExceptionRecord::query()->where('status', 0)->count(),
|
||||||
'processing' => ExceptionRecord::query()->where('status', 1)->count(),
|
'processing' => ExceptionRecord::query()->where('status', 1)->count(),
|
||||||
'completed_today' => (clone $query)->whereBetween('repaired_at', [$todayStart, $todayEnd - 1])->count(),
|
'completed_today' => (clone $query)->count(),
|
||||||
|
'process_breakdown' => $this->buildTodayProcessStepBreakdown(clone $query),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,6 +1268,16 @@ final class DataEntryController extends Base
|
|||||||
'handled_at' => $now,
|
'handled_at' => $now,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
if ($repairResult === 'operation_defect') {
|
||||||
|
$this->createRepairResponsibilityNotifications(
|
||||||
|
$repair,
|
||||||
|
$exception,
|
||||||
|
$responsibleOperatorSnapshots,
|
||||||
|
$member,
|
||||||
|
$now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return $repair;
|
return $repair;
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -1106,6 +1287,74 @@ final class DataEntryController extends Base
|
|||||||
return Json::success('提交成功', ['id' => (string) $record->id, 'repair_no' => $record->repair_no]);
|
return Json::success('提交成功', ['id' => (string) $record->id, 'repair_no' => $record->repair_no]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Get(path: '/produce/member-notification', summary: '获取当前会员生产通知', tags: ['produce-web'])]
|
||||||
|
#[DataResponse(description: '获取成功')]
|
||||||
|
public function memberNotifications(Request $request): Response
|
||||||
|
{
|
||||||
|
$memberId = (int) $this->currentMember->id();
|
||||||
|
if ($memberId <= 0) {
|
||||||
|
return Json::fail('请先登录', [], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = max(1, (int) $request->input('page', 1));
|
||||||
|
$limit = min(50, max(1, (int) $request->input('limit', 20)));
|
||||||
|
$query = MemberNotification::query()
|
||||||
|
->where('member_id', $memberId)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
$total = $query->count();
|
||||||
|
|
||||||
|
return Json::success('ok', [
|
||||||
|
'items' => $query->forPage($page, $limit)->get(),
|
||||||
|
'total' => $total,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(path: '/produce/member-notification/unread-count', summary: '获取当前会员未读生产通知数', tags: ['produce-web'])]
|
||||||
|
#[DataResponse(description: '获取成功')]
|
||||||
|
public function memberNotificationUnreadCount(): Response
|
||||||
|
{
|
||||||
|
$memberId = (int) $this->currentMember->id();
|
||||||
|
if ($memberId <= 0) {
|
||||||
|
return Json::fail('请先登录', [], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = MemberNotification::query()
|
||||||
|
->where('member_id', $memberId)
|
||||||
|
->where('status', 0)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return Json::success('ok', ['unread_count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Put(path: '/produce/member-notification/read', summary: '标记当前会员生产通知已读', tags: ['produce-web'])]
|
||||||
|
#[SimpleResponse(description: '操作成功')]
|
||||||
|
public function readMemberNotification(Request $request): Response
|
||||||
|
{
|
||||||
|
$memberId = (int) $this->currentMember->id();
|
||||||
|
$notificationId = trim((string) $request->input('id', ''));
|
||||||
|
if ($memberId <= 0) {
|
||||||
|
return Json::fail('请先登录', [], 401);
|
||||||
|
}
|
||||||
|
if ($notificationId === '') {
|
||||||
|
return Json::fail('通知ID不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = MemberNotification::query()
|
||||||
|
->where('member_id', $memberId)
|
||||||
|
->whereKey($notificationId)
|
||||||
|
->first();
|
||||||
|
if (!$notification) {
|
||||||
|
return Json::fail('通知不存在');
|
||||||
|
}
|
||||||
|
if ((int) $notification->status === 1) {
|
||||||
|
return Json::success('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification->fill(['status' => 1, 'read_at' => time()])->save();
|
||||||
|
return Json::success('ok');
|
||||||
|
}
|
||||||
|
|
||||||
#[OA\Post(path: '/produce/exception/record', summary: '提交异常记录', tags: ['produce-web'])]
|
#[OA\Post(path: '/produce/exception/record', summary: '提交异常记录', tags: ['produce-web'])]
|
||||||
#[SimpleResponse(description: '提交成功')]
|
#[SimpleResponse(description: '提交成功')]
|
||||||
#[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')]
|
#[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')]
|
||||||
@@ -1382,9 +1631,17 @@ final class DataEntryController extends Base
|
|||||||
protected function applyExceptionRecordFilter($query, Request $request): void
|
protected function applyExceptionRecordFilter($query, Request $request): void
|
||||||
{
|
{
|
||||||
$userCode = trim((string) $request->input('user_code', ''));
|
$userCode = trim((string) $request->input('user_code', ''));
|
||||||
|
$startAt = $this->parseQueryDate($request->input('start_date'));
|
||||||
|
$endAt = $this->parseQueryDate($request->input('end_date'), true);
|
||||||
if ($userCode !== '') {
|
if ($userCode !== '') {
|
||||||
$query->where('user_code', $userCode);
|
$query->where('user_code', $userCode);
|
||||||
}
|
}
|
||||||
|
if ($startAt !== null) {
|
||||||
|
$query->where('reported_at', '>=', $startAt);
|
||||||
|
}
|
||||||
|
if ($endAt !== null) {
|
||||||
|
$query->where('reported_at', '<=', $endAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function applyScanDataFilter($query, Request $request): void
|
protected function applyScanDataFilter($query, Request $request): void
|
||||||
@@ -1395,6 +1652,8 @@ final class DataEntryController extends Base
|
|||||||
$stepId = (int) $request->input('step_id', 0);
|
$stepId = (int) $request->input('step_id', 0);
|
||||||
$statusId = (int) $request->input('status_id', 0);
|
$statusId = (int) $request->input('status_id', 0);
|
||||||
$scanResult = $request->input('scan_result', '');
|
$scanResult = $request->input('scan_result', '');
|
||||||
|
$startAt = $this->parseQueryDate($request->input('start_date'));
|
||||||
|
$endAt = $this->parseQueryDate($request->input('end_date'), true);
|
||||||
|
|
||||||
if ($orderNo !== '') {
|
if ($orderNo !== '') {
|
||||||
$query->where('order_no', $orderNo);
|
$query->where('order_no', $orderNo);
|
||||||
@@ -1415,12 +1674,19 @@ final class DataEntryController extends Base
|
|||||||
$query->where('scan_result', (int) $scanResult);
|
$query->where('scan_result', (int) $scanResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($startAt !== null) {
|
||||||
|
$query->where('scan_time', '>=', $startAt);
|
||||||
|
}
|
||||||
|
if ($endAt !== null) {
|
||||||
|
$query->where('scan_time', '<=', $endAt);
|
||||||
|
}
|
||||||
|
|
||||||
$recentOnly = (int) ProduceConfig::query()
|
$recentOnly = (int) ProduceConfig::query()
|
||||||
->where('group_code', 'basic_setting')
|
->where('group_code', 'basic_setting')
|
||||||
->where('code', 'recent_records_today_only')
|
->where('code', 'recent_records_today_only')
|
||||||
->value('content');
|
->value('content');
|
||||||
|
|
||||||
if ($recentOnly === 1 && $orderNo === '') {
|
if ($recentOnly === 1 && $orderNo === '' && $startAt === null && $endAt === null) {
|
||||||
$start = strtotime(date('Y-m-d 00:00:00'));
|
$start = strtotime(date('Y-m-d 00:00:00'));
|
||||||
$end = strtotime(date('Y-m-d 23:59:59'));
|
$end = strtotime(date('Y-m-d 23:59:59'));
|
||||||
$query->whereBetween('scan_time', [$start, $end]);
|
$query->whereBetween('scan_time', [$start, $end]);
|
||||||
@@ -1429,10 +1695,17 @@ final class DataEntryController extends Base
|
|||||||
|
|
||||||
protected function applyProductRecordFilter($query, Request $request): void
|
protected function applyProductRecordFilter($query, Request $request): void
|
||||||
{
|
{
|
||||||
|
$userCode = trim((string) $request->input('user_code', ''));
|
||||||
$processId = (int) $request->input('process_id', 0);
|
$processId = (int) $request->input('process_id', 0);
|
||||||
$stepId = (int) $request->input('step_id', 0);
|
$stepId = (int) $request->input('step_id', 0);
|
||||||
$statusId = (int) $request->input('status_id', 0);
|
$statusId = (int) $request->input('status_id', 0);
|
||||||
$scanResult = $request->input('scan_result', '');
|
$scanResult = $request->input('scan_result', '');
|
||||||
|
$startAt = $this->parseQueryDate($request->input('start_date'));
|
||||||
|
$endAt = $this->parseQueryDate($request->input('end_date'), true);
|
||||||
|
|
||||||
|
if ($userCode !== '') {
|
||||||
|
$query->where('user_code', $userCode);
|
||||||
|
}
|
||||||
|
|
||||||
if ($processId > 0) {
|
if ($processId > 0) {
|
||||||
$query->where('process_id', $processId);
|
$query->where('process_id', $processId);
|
||||||
@@ -1447,12 +1720,19 @@ final class DataEntryController extends Base
|
|||||||
$query->where('scan_result', (int) $scanResult);
|
$query->where('scan_result', (int) $scanResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($startAt !== null) {
|
||||||
|
$query->where('created_at', '>=', $startAt);
|
||||||
|
}
|
||||||
|
if ($endAt !== null) {
|
||||||
|
$query->where('created_at', '<=', $endAt);
|
||||||
|
}
|
||||||
|
|
||||||
$recentOnly = (int) ProduceConfig::query()
|
$recentOnly = (int) ProduceConfig::query()
|
||||||
->where('group_code', 'basic_setting')
|
->where('group_code', 'basic_setting')
|
||||||
->where('code', 'recent_records_today_only')
|
->where('code', 'recent_records_today_only')
|
||||||
->value('content');
|
->value('content');
|
||||||
|
|
||||||
if ($recentOnly === 1) {
|
if ($recentOnly === 1 && $startAt === null && $endAt === null) {
|
||||||
$start = strtotime(date('Y-m-d 00:00:00'));
|
$start = strtotime(date('Y-m-d 00:00:00'));
|
||||||
$end = strtotime(date('Y-m-d 23:59:59'));
|
$end = strtotime(date('Y-m-d 23:59:59'));
|
||||||
$query->whereBetween('created_at', [$start, $end]);
|
$query->whereBetween('created_at', [$start, $end]);
|
||||||
@@ -1475,6 +1755,71 @@ final class DataEntryController extends Base
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 创建作业不良责任通知,每名责任人仅保留一条通知。 */
|
||||||
|
private function createRepairResponsibilityNotifications(
|
||||||
|
RepairRecord $repair,
|
||||||
|
ExceptionRecord $exception,
|
||||||
|
array $snapshots,
|
||||||
|
Member $repairer,
|
||||||
|
int $now
|
||||||
|
): void {
|
||||||
|
$snapshotsByUserCode = [];
|
||||||
|
foreach ($snapshots as $snapshot) {
|
||||||
|
$userCode = trim((string) ($snapshot['user_code'] ?? ''));
|
||||||
|
if ($userCode !== '') {
|
||||||
|
$snapshotsByUserCode[$userCode][] = $snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($snapshotsByUserCode as $userCode => $userSnapshots) {
|
||||||
|
$responsibleMember = Member::query()->where('username', $userCode)->first();
|
||||||
|
if (!$responsibleMember) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$steps = array_values(array_unique(array_filter(array_map(static function (array $snapshot): string {
|
||||||
|
return implode(' / ', array_filter([
|
||||||
|
trim((string) ($snapshot['process_name'] ?? '')),
|
||||||
|
trim((string) ($snapshot['step_name'] ?? '')),
|
||||||
|
]));
|
||||||
|
}, $userSnapshots))));
|
||||||
|
MemberNotification::query()->create([
|
||||||
|
'id' => Snowflake::generate(),
|
||||||
|
'member_id' => $responsibleMember->id,
|
||||||
|
'user_code' => $userCode,
|
||||||
|
'type' => 'repair_responsibility',
|
||||||
|
'title' => '维修作业不良责任通知',
|
||||||
|
'content' => $this->formatRepairResponsibilityContent($repair, $exception, $steps, $repairer),
|
||||||
|
'repair_id' => $repair->id,
|
||||||
|
'repair_no' => $repair->repair_no,
|
||||||
|
'order_no' => $repair->order_no,
|
||||||
|
'status' => 0,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组装维修定责通知的原因说明。 */
|
||||||
|
private function formatRepairResponsibilityContent(
|
||||||
|
RepairRecord $repair,
|
||||||
|
ExceptionRecord $exception,
|
||||||
|
array $steps,
|
||||||
|
Member $repairer
|
||||||
|
): string {
|
||||||
|
$content = [
|
||||||
|
sprintf('订单 %s 已完成维修,维修判定为作业不良责任。', $repair->order_no),
|
||||||
|
$steps === [] ? '' : '责任工序/步骤:' . implode('、', $steps),
|
||||||
|
$exception->reason_name ? '异常原因:' . $exception->reason_name : '',
|
||||||
|
$exception->issue_name ? '具体问题:' . $exception->issue_name : '',
|
||||||
|
$repair->remark ? '维修说明:' . $repair->remark : '',
|
||||||
|
'维修单:' . $repair->repair_no,
|
||||||
|
'提交人:' . ($repairer->nickname ?: $repairer->username),
|
||||||
|
];
|
||||||
|
|
||||||
|
return implode("\n", array_filter($content));
|
||||||
|
}
|
||||||
|
|
||||||
protected function handleNextStepTransitions(ScanData $currentScanData, Request $request, Member $member, int $now): void
|
protected function handleNextStepTransitions(ScanData $currentScanData, Request $request, Member $member, int $now): void
|
||||||
{
|
{
|
||||||
$previousRelations = \plugin\produce\app\model\StepRelation::query()
|
$previousRelations = \plugin\produce\app\model\StepRelation::query()
|
||||||
|
|||||||
@@ -186,6 +186,27 @@ final class DataEntryController
|
|||||||
return $this->delegate->repairRecord($request, $repairRecordService);
|
return $this->delegate->repairRecord($request, $repairRecordService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Get(path: '/produce/member-notification', summary: 'APP 获取当前会员生产通知', tags: ['produce-app'])]
|
||||||
|
#[DataResponse(description: '获取成功')]
|
||||||
|
public function memberNotifications(Request $request): Response
|
||||||
|
{
|
||||||
|
return $this->delegate->memberNotifications($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(path: '/produce/member-notification/unread-count', summary: 'APP 获取未读生产通知数', tags: ['produce-app'])]
|
||||||
|
#[DataResponse(description: '获取成功')]
|
||||||
|
public function memberNotificationUnreadCount(): Response
|
||||||
|
{
|
||||||
|
return $this->delegate->memberNotificationUnreadCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Put(path: '/produce/member-notification/read', summary: 'APP 标记生产通知已读', tags: ['produce-app'])]
|
||||||
|
#[SimpleResponse(description: '操作成功')]
|
||||||
|
public function readMemberNotification(Request $request): Response
|
||||||
|
{
|
||||||
|
return $this->delegate->readMemberNotification($request);
|
||||||
|
}
|
||||||
|
|
||||||
#[OA\Post(path: '/produce/exception/record', summary: 'APP 提交异常记录', tags: ['produce-app'])]
|
#[OA\Post(path: '/produce/exception/record', summary: 'APP 提交异常记录', tags: ['produce-app'])]
|
||||||
#[SimpleResponse(description: '提交成功')]
|
#[SimpleResponse(description: '提交成功')]
|
||||||
#[AllowAnonymous(requireToken: false, requirePermission: false, description: 'APP 公共接口')]
|
#[AllowAnonymous(requireToken: false, requirePermission: false, description: 'APP 公共接口')]
|
||||||
@@ -208,6 +229,13 @@ final class DataEntryController
|
|||||||
return $this->delegate->productStatistics($request);
|
return $this->delegate->productStatistics($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Get(path: '/produce/dashboard/today', summary: 'APP 获取首页今日生产总览', tags: ['produce-app'])]
|
||||||
|
#[DataResponse(description: '获取成功')]
|
||||||
|
public function todayDashboard(): Response
|
||||||
|
{
|
||||||
|
return $this->delegate->todayDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
#[OA\Get(path: '/produce/product-record/user-code-stats', summary: 'APP 整机用户统计', tags: ['produce-app'])]
|
#[OA\Get(path: '/produce/product-record/user-code-stats', summary: 'APP 整机用户统计', tags: ['produce-app'])]
|
||||||
#[DataResponse(description: '获取成功')]
|
#[DataResponse(description: '获取成功')]
|
||||||
public function productUserCodeStats(Request $request): Response
|
public function productUserCodeStats(Request $request): Response
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace plugin\produce\app\model;
|
||||||
|
|
||||||
|
class MemberNotification extends BaseModel
|
||||||
|
{
|
||||||
|
protected $table = 'produce_member_notification';
|
||||||
|
|
||||||
|
protected $appends = ['created_date', 'read_date'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'id', 'member_id', 'user_code', 'type', 'title', 'content', 'repair_id', 'repair_no',
|
||||||
|
'order_no', 'status', 'read_at', 'created_at', 'updated_at', 'deleted_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'member_id' => 'integer',
|
||||||
|
'repair_id' => 'integer',
|
||||||
|
'status' => 'integer',
|
||||||
|
'read_at' => 'integer',
|
||||||
|
'created_at' => 'integer',
|
||||||
|
'updated_at' => 'integer',
|
||||||
|
'deleted_at' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getReadDateAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->read_at ? date('Y-m-d H:i:s', (int) $this->read_at) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Schema\Builder;
|
||||||
|
|
||||||
|
return new class {
|
||||||
|
public function up(Builder $schema): void
|
||||||
|
{
|
||||||
|
$table = 'produce_member_notification';
|
||||||
|
if ($schema->hasTable($table)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema->create($table, function (Blueprint $table): void {
|
||||||
|
$table->bigInteger('id')->primary()->comment('主键');
|
||||||
|
$table->bigInteger('member_id')->nullable()->comment('接收会员ID');
|
||||||
|
$table->string('user_code', 64)->comment('接收用户编码');
|
||||||
|
$table->string('type', 32)->default('repair_responsibility')->comment('通知类型');
|
||||||
|
$table->string('title', 120)->comment('通知标题');
|
||||||
|
$table->text('content')->comment('通知内容');
|
||||||
|
$table->bigInteger('repair_id')->nullable()->comment('维修记录ID');
|
||||||
|
$table->string('repair_no', 64)->nullable()->comment('维修单号');
|
||||||
|
$table->string('order_no', 64)->nullable()->comment('订单号');
|
||||||
|
$table->tinyInteger('status')->default(0)->comment('阅读状态:0未读,1已读');
|
||||||
|
$table->bigInteger('read_at')->nullable()->comment('阅读时间');
|
||||||
|
$table->bigInteger('created_at')->nullable()->comment('创建时间');
|
||||||
|
$table->bigInteger('updated_at')->nullable()->comment('更新时间');
|
||||||
|
$table->bigInteger('deleted_at')->nullable()->comment('删除时间');
|
||||||
|
$table->index(['member_id', 'status'], 'idx_member_notification_status');
|
||||||
|
$table->index(['user_code', 'created_at'], 'idx_member_notification_user_code');
|
||||||
|
$table->index('repair_id', 'idx_member_notification_repair_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Builder $schema): void
|
||||||
|
{
|
||||||
|
$table = 'produce_member_notification';
|
||||||
|
if (!$schema->hasTable($table)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema->drop($table);
|
||||||
|
}
|
||||||
|
};
|
||||||
+33
-4
@@ -400,6 +400,33 @@ const matchSpecificRule = (rule: ProduceDataEntrySpecificRule, valueMap: Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasExistingRecordForRule = (rule: ProduceDataEntrySpecificRule) => {
|
||||||
|
const matchField = rule.match_field || 'process_order_no'
|
||||||
|
const currentValue = String(({
|
||||||
|
process_order_no: processOrderNo.value,
|
||||||
|
order_no: processOrderNo.value,
|
||||||
|
product_code: productCode.value,
|
||||||
|
user_code: userCode.value,
|
||||||
|
} as Record<string, string>)[matchField] || '').trim()
|
||||||
|
if (!currentValue) return false
|
||||||
|
const matched = recentRecords.value.some((record) => {
|
||||||
|
const recordValue = String((record as Record<string, any>)[matchField] || (matchField === 'order_no' ? record.process_order_no : '')).trim()
|
||||||
|
return recordValue === currentValue
|
||||||
|
})
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDuplicateRuleMessage = (rule: ProduceDataEntrySpecificRule) => {
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
order_no: '加工单号',
|
||||||
|
process_order_no: '加工单号',
|
||||||
|
product_code: '商品编码',
|
||||||
|
user_code: '用户编码',
|
||||||
|
}
|
||||||
|
const field = rule.match_field || 'process_order_no'
|
||||||
|
return `${fieldLabels[field] || '当前数据'}已录入,当前规则不允许重复扫码`
|
||||||
|
}
|
||||||
|
|
||||||
const validateSpecificRules = () => {
|
const validateSpecificRules = () => {
|
||||||
if (!specificRules.value.length) {
|
if (!specificRules.value.length) {
|
||||||
return true
|
return true
|
||||||
@@ -415,11 +442,11 @@ const validateSpecificRules = () => {
|
|||||||
if ((rule.apply_mode || 'order') !== 'machine' && rule.apply_mode !== 'both') {
|
if ((rule.apply_mode || 'order') !== 'machine' && rule.apply_mode !== 'both') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context)
|
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context) && hasExistingRecordForRule(rule)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (blockedRule) {
|
if (blockedRule) {
|
||||||
ElMessage.warning(blockedRule.rule_name || '当前整机数据命中特定规则,禁止提交')
|
ElMessage.warning(getDuplicateRuleMessage(blockedRule))
|
||||||
const matchField = blockedRule.match_field || 'process_order_no'
|
const matchField = blockedRule.match_field || 'process_order_no'
|
||||||
if (matchField === 'product_code') {
|
if (matchField === 'product_code') {
|
||||||
focusProductCodeInput()
|
focusProductCodeInput()
|
||||||
@@ -484,12 +511,13 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitEntry = async () => {
|
const submitEntry = async () => {
|
||||||
|
if (submitting.value) return
|
||||||
if (!validateBeforeSubmit()) return
|
if (!validateBeforeSubmit()) return
|
||||||
const passed = await runUserCodeCheck(userCode.value)
|
const passed = await runUserCodeCheck(userCode.value)
|
||||||
if (!passed) { emit('play-error'); nextTick(() => focusUserCodeInput()); return }
|
if (!passed) { emit('play-error'); nextTick(() => focusUserCodeInput()); return }
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const result = await submitProductRecord({
|
const payload = {
|
||||||
process_order_no: processOrderNo.value.trim(),
|
process_order_no: processOrderNo.value.trim(),
|
||||||
product_code: productCode.value.trim(),
|
product_code: productCode.value.trim(),
|
||||||
quantity: Number(quantity.value) || 1,
|
quantity: Number(quantity.value) || 1,
|
||||||
@@ -497,7 +525,8 @@ const submitEntry = async () => {
|
|||||||
step_id: props.selectedStep,
|
step_id: props.selectedStep,
|
||||||
user_code: userCode.value.trim(),
|
user_code: userCode.value.trim(),
|
||||||
scan_result: 1,
|
scan_result: 1,
|
||||||
})
|
}
|
||||||
|
const result = await submitProductRecord(payload)
|
||||||
if (Number(result?.code) !== 0) throw new Error(result?.msg || '录入失败')
|
if (Number(result?.code) !== 0) throw new Error(result?.msg || '录入失败')
|
||||||
ElMessage.success('录入成功')
|
ElMessage.success('录入成功')
|
||||||
emit('play-success')
|
emit('play-success')
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 维修作业不良责任通知
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- 新增 `produce_member_notification`,用于保存生产 APP 会员通知及已读状态。
|
||||||
|
- 维修判定为“作业不良”并选择责任人员后,系统按责任人员用户编码去重创建通知。
|
||||||
|
- 通知说明订单、责任工序步骤、异常原因、具体问题、维修说明、维修单号及提交人。
|
||||||
|
- APP 首页铃铛显示未读数量;点击可进入生产通知列表,点击未读通知后标记为已读。
|
||||||
|
|
||||||
|
## 兼容说明
|
||||||
|
|
||||||
|
- 责任人员的用户编码找不到会员账号时,不创建通知,也不影响维修记录提交。
|
||||||
|
- 通知查询和标记已读均根据当前 Token 对应会员过滤,前端不能指定其他人的通知。
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
执行插件迁移 `2026_09_04_000001_create_produce_member_notification_table.php` 后生效。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 修正生产通知加载组件
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
生产通知页引用了项目未安装的 `u-loading-icon`,Vite 无法解析其组件路径,导致页面编译失败。
|
||||||
|
|
||||||
|
## 修正
|
||||||
|
|
||||||
|
改为项目实际存在的 uView Pro `u-loading` 组件,并保留原有圆形、蓝色、32rpx 的加载效果。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 移除首页扫码原型入口
|
||||||
|
|
||||||
|
保留 `frontend/app/pages/scan/prototype.vue` 页面文件及其路由,仅从 APP 首页工作区菜单中移除“UTS 扫码原型”入口。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 修复整机模式提交反馈
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- APP 整机录入从登录资料读取用户编码,支持 `username` 和 `user_code`,不再回退到不存在的模拟编码。
|
||||||
|
- APP 整机录入提交前增加登录信息有效性校验,并将接口失败原因通过提示展示给用户。
|
||||||
|
- APP 与 Web 整机录入增加提交中的重复点击保护。
|
||||||
|
- APP 提交整机记录时对用户编码进行统一去空格处理。
|
||||||
|
- 修正“禁止重复”特定规则的判断,仅在已有相同整机记录时拦截,避免首次录入被错误阻止。
|
||||||
|
|
||||||
|
## 根因确认
|
||||||
|
|
||||||
|
后台启用“整机扫码”禁止重复规则时,重复提交会按规则拦截并提示规则名称;该行为属于配置生效,并非接口异常。
|
||||||
|
|
||||||
|
## 验证说明
|
||||||
|
|
||||||
|
- 已通过本地接口使用有效工序、步骤和用户编码验证 `produce/product-record/record` 可正常写入。
|
||||||
|
- 前端修改已完成静态代码核对,建议在 Web 和 Android 真机分别提交一条测试记录,确认成功提示及最近记录刷新。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 同步生产录入模板源
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- 将 Web 端整机录入组件的最新实现同步至生产插件模板源。
|
||||||
|
- 模板源与 Web 运行目录的整机录入组件、数据接口文件保持一致。
|
||||||
|
- 同步内容包含重复扫码规则判断、提交保护及提交失败提示。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `backend/plugin/produce/resource/template/web/pages/data-entry/components/MachineEntryPanel.vue`
|
||||||
|
- `backend/plugin/produce/resource/template/web/api/data-entry.ts`
|
||||||
|
- `frontend/web/app/apps/produce/pages/data-entry/components/MachineEntryPanel.vue`
|
||||||
|
- `frontend/web/app/apps/produce/api/data-entry.ts`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 优化整机重复扫码提示
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- 整机录入命中禁止重复扫码规则时,不再只显示后台规则名称。
|
||||||
|
- 根据规则匹配字段展示完整提示,例如“加工单号已录入,当前规则不允许重复扫码”。
|
||||||
|
- Web 插件模板源已先更新,并同步至 Web 运行目录;APP 使用相同提示语义。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `backend/plugin/produce/resource/template/web/pages/data-entry/components/MachineEntryPanel.vue`
|
||||||
|
- `frontend/web/app/apps/produce/pages/data-entry/components/MachineEntryPanel.vue`
|
||||||
|
- `frontend/app/pages/entry/machine.vue`
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 首页不良责任订单按月展示
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- 首页“不良责任订单”改为统计当前登录用户本月承担作业不良责任的维修记录。
|
||||||
|
- 区域显示本月总次数,并按维修时间倒序展示最近 5 条数据。
|
||||||
|
- 首页其余“今日数据”、扫码趋势仍保持当天统计口径。
|
||||||
|
- 暗色主题下,本月统计说明文字同步使用暗色主题辅助文字颜色。
|
||||||
|
- 最近记录仅展示订单号和不良原因,不再展示维修单号。
|
||||||
|
- “订单扫码”工序步骤明细同步合并当前用户当天维修记录的工序步骤。
|
||||||
|
- 维修记录按记录条数统计,兼容维修表不存在数量字段的实际结构。
|
||||||
|
- 工序步骤名称优先读取维修记录保存的快照,与查询页面历史数据展示口径一致。
|
||||||
|
- 修正统计查询仅在维修表读取快照字段,避免扫码表因不存在快照列导致接口 500。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `backend/plugin/produce/app/api/controller/DataEntryController.php`
|
||||||
|
- `frontend/app/pages/index/index.vue`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 首页维修工序统计字段兼容
|
||||||
|
|
||||||
|
修复首页生产总览接口的工序步骤统计查询:仅维修记录读取工序和步骤快照字段,扫码记录与整机记录继续使用各自已有的工序、步骤 ID 映射名称,避免因数据表字段差异导致接口报错。
|
||||||
|
|
||||||
|
统计页接口同时返回日期趋势和工序步骤汇总,供 APP 统计页展示真实时间区间数据。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 细化 APP 个人统计页
|
||||||
|
|
||||||
|
个人统计页改用真实的日期区间统计:核心指标区分今日扫码与本月累计,成功率和异常数按本月数据计算;新增本月工序/步骤 Top 10,最近明细读取本月当前用户记录,维修记录也纳入工序步骤统计。统计接口补充日期筛选、每日趋势和工序步骤汇总,时间范围会正确绕过“仅显示今日”配置;异常统计改为成功率、失败率和待处理比例。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 移除统计页占比区域
|
||||||
|
|
||||||
|
根据页面需求,移除 APP 统计页中的“扫码类型占比”和“异常统计”区域,同时清理对应状态、计算逻辑和样式,保留核心指标、工序步骤统计、趋势和最近明细。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 统计页增加月度日历
|
||||||
|
|
||||||
|
APP 统计页新增月度日历区域,按月份展示日期网格和数据标记,支持前后月份切换;点击当月日期后读取该日订单扫码与整机记录统计,并展示当天订单扫码、整机条码和整机数量明细,同时兼容暗色主题。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 调整统计页日历位置
|
||||||
|
|
||||||
|
将 APP 统计页的月度日历区域移动到“个人趋势”区域之后,保留月度切换、日期数据标记和当天明细交互。
|
||||||
|
|
||||||
|
当天明细进一步展示订单、整机和维修记录合并后的工序/步骤数量;汇总指标调整为订单扫码、整机数量和总计。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 调整当天明细指标名称
|
||||||
|
|
||||||
|
将统计页日历的当天明细指标调整为“订单扫码”“整机数量”“总计”,总计值包含当天订单扫码、整机记录和维修记录数量。
|
||||||
|
|
||||||
|
核心指标区采用稳定的双列卡片布局,限制内容收缩并保持指标完整显示。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 完善首页今日数据
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- 新增生产插件首页今日总览接口,按当天订单扫码、整机记录、异常提报和维修完成数据统计。
|
||||||
|
- 新增近 6 个小时扫码趋势,柱状图按实际数量自动计算比例。
|
||||||
|
- 异常处理区域改为显示已处理、处理中、待处理的实际数量及占比。
|
||||||
|
- APP 首页今日数据改为登录后加载真实数据,并展示更新时间;未登录时提示登录后查看。
|
||||||
|
- 汇总卡片调整为 2 列布局,适配手机屏幕并区分状态颜色。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 完善统计页核心指标项
|
||||||
|
|
||||||
|
统计页核心指标显示订单数、整机数、本月总计、异常提报、维修量和责任订单,按三列恢复简洁卡片布局;分别读取当前登录用户对应月份的真实统计数据,本月总计为订单扫码数加整机数;异常提报接口增加用户和日期区间筛选。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 移除成功失败核心指标
|
||||||
|
|
||||||
|
根据页面需求,从 APP 统计页核心指标中移除“成功量”和“失败量”,并清理对应的前端计算逻辑,保留六项业务统计指标。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 核心指标三列布局
|
||||||
|
|
||||||
|
按照页面要求,核心指标调整为订单数、整机数、本月总计、异常提报、维修量和责任订单六项,使用三列网格并恢复简洁卡片样式。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 修复首页维修量用户匹配
|
||||||
|
|
||||||
|
首页维修量统计同时匹配维修记录的操作人姓名和关联异常记录的用户编码,修复用户编码录入场景下查询页存在维修记录但首页显示为零的问题。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 修复整机统计用户过滤
|
||||||
|
|
||||||
|
修复整机记录统计和最近明细未应用 `user_code` 条件的问题。统计页传入当前登录用户编码后,只统计该用户的整机记录,避免将其他用户的整机数量显示到当前账号。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 首页今日数据按工序步骤与提报人统计
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- 订单扫码和整机记录增加工序/步骤明细,显示每个工序步骤的实际数量及总量。
|
||||||
|
- 异常提报增加按提报人分组的数量统计,同时保留处理状态统计。
|
||||||
|
- 首页明细支持暂无数据状态,并兼容深色主题。
|
||||||
|
|
||||||
|
## 接口数据
|
||||||
|
|
||||||
|
`/appapi/produce/dashboard/today` 和 `/api/produce/dashboard/today` 新增:
|
||||||
|
|
||||||
|
- `order_breakdown`
|
||||||
|
- `product_breakdown`
|
||||||
|
- `exception_reporters`
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 首页今日数据合并当前用户统计
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- 合并汇总与工序步骤明细,订单扫码、整机记录直接在汇总卡片中展示工序/步骤数量。
|
||||||
|
- 移除重复的独立工序步骤卡片区域。
|
||||||
|
- 异常提报统计按当前登录会员的 `username` 对应 `user_code` 过滤。
|
||||||
|
- 新增“不良责任订单”列表,从作业不良维修记录的责任人员快照中匹配当前登录会员。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 首页今日数据卡片样式重设计
|
||||||
|
|
||||||
|
## 变更内容
|
||||||
|
|
||||||
|
- 统一今日数据卡片的内边距、圆角和高度层级。
|
||||||
|
- 订单扫码、整机记录的工序步骤明细改为卡片内独立行,避免文字贴边或撑破布局。
|
||||||
|
- 普通统计卡片保持数字与标签居中,工序明细卡片使用更清晰的横向标题布局。
|
||||||
|
- 增加明细行背景、间距和长文本省略处理,兼容现有深色主题。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 首页不良责任订单展示
|
||||||
|
|
||||||
|
将首页原“我的异常提报”区域改为“不良责任订单”,数据来源调整为作业不良维修记录中的责任人员快照,仅展示当前登录用户承担责任的当日订单,并按订单汇总责任次数。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 首页删除处理状态显示
|
||||||
|
|
||||||
|
移除首页“不良责任订单”区域下方的处理状态进度展示,仅保留当前登录用户承担责任的订单及责任次数。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 首页今日数据仅显示当前用户
|
||||||
|
|
||||||
|
今日总览接口统一按当前登录会员过滤:
|
||||||
|
|
||||||
|
- 订单扫码、整机记录和异常提报按 `user_code` 匹配当前会员账号。
|
||||||
|
- 维修完成按维修记录保存的操作人昵称或账号匹配当前会员。
|
||||||
|
- 工序/步骤明细、小时趋势和不良责任订单均基于上述过滤后的数据计算。
|
||||||
@@ -62,6 +62,18 @@ export function getProductStatistics(params = {}) {
|
|||||||
return request.get('/produce/product-record/statistics', params)
|
return request.get('/produce/product-record/statistics', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getExceptionStatistics(params = {}) {
|
||||||
|
return request.get('/produce/exception/statistics', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepairStatistics(params = {}) {
|
||||||
|
return request.get('/produce/repair-record/statistics', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTodayDashboard() {
|
||||||
|
return request.get('/produce/dashboard/today')
|
||||||
|
}
|
||||||
|
|
||||||
export function submitProductRecord(data) {
|
export function submitProductRecord(data) {
|
||||||
return request.post('/produce/product-record/record', data)
|
return request.post('/produce/product-record/record', data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import request from '@/utils/request.js'
|
||||||
|
|
||||||
|
export function getMemberNotifications(params = {}) {
|
||||||
|
return request.get('/produce/member-notification', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnreadNotificationCount() {
|
||||||
|
return request.get('/produce/member-notification/unread-count')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markNotificationRead(id) {
|
||||||
|
return request.put('/produce/member-notification/read', { id })
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"name" : "scanapp",
|
"name" : "scanapp",
|
||||||
"appid" : "__UNI__04C6DBA",
|
"appid" : "__UNI__04C6DBA",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "1.1.0",
|
"versionName" : "1.3.0",
|
||||||
"versionCode" : 110,
|
"versionCode" : 130,
|
||||||
"transformPx" : false,
|
"transformPx" : false,
|
||||||
/* 5+App特有相关 */
|
/* 5+App特有相关 */
|
||||||
"app-plus" : {
|
"app-plus" : {
|
||||||
@@ -46,10 +46,14 @@
|
|||||||
},
|
},
|
||||||
/* ios打包配置 */
|
/* ios打包配置 */
|
||||||
"ios" : {
|
"ios" : {
|
||||||
"dSYMs" : false
|
"dSYMs" : false,
|
||||||
|
"idfa" : false
|
||||||
},
|
},
|
||||||
/* SDK配置 */
|
/* SDK配置 */
|
||||||
"sdkConfigs" : {}
|
"sdkConfigs" : {},
|
||||||
|
"splashscreen" : {
|
||||||
|
"androidStyle" : "common"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/* 快应用特有相关 */
|
/* 快应用特有相关 */
|
||||||
|
|||||||
@@ -78,6 +78,12 @@
|
|||||||
"navigationBarTitleText": "待维修订单"
|
"navigationBarTitleText": "待维修订单"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/notification/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "生产通知"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/settings/index",
|
"path": "pages/settings/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
getCurrentUserCode() {
|
getCurrentUserCode() {
|
||||||
const user = uni.getStorageSync('scanapp_user') || {}
|
const user = uni.getStorageSync('scanapp_user') || {}
|
||||||
return user.username || user.nickname || 'U001'
|
return String(user.username || user.user_code || '').trim()
|
||||||
},
|
},
|
||||||
loadPdaMode() {
|
loadPdaMode() {
|
||||||
const userCode = this.getCurrentUserCode()
|
const userCode = this.getCurrentUserCode()
|
||||||
@@ -307,6 +307,31 @@ export default {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
hasExistingRecordForRule(rule) {
|
||||||
|
const matchField = rule.match_field || 'process_order_no'
|
||||||
|
const valueMap = {
|
||||||
|
process_order_no: this.form.processOrderNo,
|
||||||
|
order_no: this.form.processOrderNo,
|
||||||
|
product_code: this.form.productCode,
|
||||||
|
user_code: this.currentUser.userCode
|
||||||
|
}
|
||||||
|
const currentValue = String(valueMap[matchField] || '').trim()
|
||||||
|
if (!currentValue) return false
|
||||||
|
return this.recentRecords.some((record) => {
|
||||||
|
const recordValue = String(record[matchField] || (matchField === 'order_no' ? record.process_order_no : '')).trim()
|
||||||
|
return recordValue === currentValue
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getDuplicateRuleMessage(rule) {
|
||||||
|
const fieldLabels = {
|
||||||
|
order_no: '加工单号',
|
||||||
|
process_order_no: '加工单号',
|
||||||
|
product_code: '商品编码',
|
||||||
|
user_code: '用户编码'
|
||||||
|
}
|
||||||
|
const field = rule.match_field || 'process_order_no'
|
||||||
|
return `${fieldLabels[field] || '当前数据'}已录入,当前规则不允许重复扫码`
|
||||||
|
},
|
||||||
validateSpecificRules() {
|
validateSpecificRules() {
|
||||||
const context = {
|
const context = {
|
||||||
order_no: this.form.processOrderNo.trim(),
|
order_no: this.form.processOrderNo.trim(),
|
||||||
@@ -316,10 +341,10 @@ export default {
|
|||||||
}
|
}
|
||||||
const blockedRule = this.specificRules.find((rule) => {
|
const blockedRule = this.specificRules.find((rule) => {
|
||||||
if (rule.apply_mode && rule.apply_mode !== 'machine' && rule.apply_mode !== 'both') return false
|
if (rule.apply_mode && rule.apply_mode !== 'machine' && rule.apply_mode !== 'both') return false
|
||||||
return Number(rule.rule_type) === 2 && this.matchSpecificRule(rule, context)
|
return Number(rule.rule_type) === 2 && this.matchSpecificRule(rule, context) && this.hasExistingRecordForRule(rule)
|
||||||
})
|
})
|
||||||
if (blockedRule) {
|
if (blockedRule) {
|
||||||
uni.showToast({ title: blockedRule.rule_name || '当前整机数据命中特定规则,禁止提交', icon: 'none' })
|
uni.showToast({ title: this.getDuplicateRuleMessage(blockedRule), icon: 'none' })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -394,6 +419,10 @@ export default {
|
|||||||
this.form.quantity = Math.max(1, Number(this.form.quantity || 1) - 1)
|
this.form.quantity = Math.max(1, Number(this.form.quantity || 1) - 1)
|
||||||
},
|
},
|
||||||
validateForm() {
|
validateForm() {
|
||||||
|
if (!this.currentUser.userCode) {
|
||||||
|
uni.showToast({ title: '登录信息已失效,请重新登录', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (!this.form.processOrderNo.trim()) {
|
if (!this.form.processOrderNo.trim()) {
|
||||||
uni.showToast({ title: '请输入加工单号', icon: 'none' })
|
uni.showToast({ title: '请输入加工单号', icon: 'none' })
|
||||||
return false
|
return false
|
||||||
@@ -430,26 +459,32 @@ export default {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async submitEntry() {
|
async submitEntry() {
|
||||||
|
if (this.submitting) return
|
||||||
await this.loadDataEntryConfig()
|
await this.loadDataEntryConfig()
|
||||||
if (!this.validateForm()) return
|
if (!this.validateForm()) return
|
||||||
this.submitting = true
|
this.submitting = true
|
||||||
try {
|
try {
|
||||||
const processItem = this.processOptions[this.selectedProcessIndex]
|
const processItem = this.processOptions[this.selectedProcessIndex]
|
||||||
const stepItem = this.stepOptions[this.selectedStepIndex]
|
const stepItem = this.stepOptions[this.selectedStepIndex]
|
||||||
await submitProductRecord({
|
const payload = {
|
||||||
process_order_no: this.form.processOrderNo.trim(),
|
process_order_no: this.form.processOrderNo.trim(),
|
||||||
product_code: this.form.productCode.trim(),
|
product_code: this.form.productCode.trim(),
|
||||||
quantity: Number(this.form.quantity) || 1,
|
quantity: Number(this.form.quantity) || 1,
|
||||||
user_code: this.currentUser.userCode,
|
user_code: this.currentUser.userCode.trim(),
|
||||||
process_id: processItem?.value,
|
process_id: processItem?.value,
|
||||||
step_id: stepItem?.value,
|
step_id: stepItem?.value,
|
||||||
scan_result: 1
|
scan_result: 1
|
||||||
})
|
}
|
||||||
|
await submitProductRecord(payload)
|
||||||
uni.showToast({ title: '整机录入成功', icon: 'success' })
|
uni.showToast({ title: '整机录入成功', icon: 'success' })
|
||||||
this.resetForm()
|
this.resetForm()
|
||||||
await this.loadPageData()
|
await this.loadPageData()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[scanapp] 整机录入失败', error)
|
console.error('[scanapp] 整机录入失败', error)
|
||||||
|
uni.showToast({
|
||||||
|
title: error?.message || '整机录入失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
this.submitting = false
|
this.submitting = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
<text class="hero-title">扫码工作台</text>
|
<text class="hero-title">扫码工作台</text>
|
||||||
<text class="hero-subtitle">高效完成生产扫码与库存管理</text>
|
<text class="hero-subtitle">高效完成生产扫码与库存管理</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="hero-action">
|
<view class="hero-action" @tap="openNotifications">
|
||||||
<u-icon name="bell-fill" color="#1677ff" size="38" />
|
<u-icon name="bell-fill" color="#1677ff" size="38" />
|
||||||
|
<text v-if="unreadNotificationCount > 0" class="notification-badge">{{ unreadNotificationText }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -42,23 +43,32 @@
|
|||||||
<view class="chart-header">
|
<view class="chart-header">
|
||||||
<view>
|
<view>
|
||||||
<text class="chart-title">今日数据</text>
|
<text class="chart-title">今日数据</text>
|
||||||
<text class="chart-subtitle">扫码与异常概览</text>
|
<text class="chart-subtitle">扫码、异常与维修实时概览</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="chart-badge">实时</view>
|
<view class="chart-badge">{{ dashboardUpdatedAt }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="summary-grid">
|
<view class="summary-grid">
|
||||||
<view v-for="item in summaries" :key="item.label" class="summary-card">
|
<view v-for="item in summaries" :key="item.label" class="summary-card" :class="[item.type, { 'has-details': item.details && item.details.length }]">
|
||||||
<text class="summary-value">{{ item.value }}</text>
|
<view class="summary-main">
|
||||||
<text class="summary-label">{{ item.label }}</text>
|
<text class="summary-value" :class="item.type">{{ item.value }}</text>
|
||||||
|
<text class="summary-label">{{ item.label }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.details && item.details.length" class="summary-detail-list">
|
||||||
|
<view v-for="detail in item.details" :key="detail.key" class="summary-detail-row">
|
||||||
|
<text class="summary-detail-name">{{ detail.name }}</text>
|
||||||
|
<text class="summary-detail-value">{{ detail.quantity }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="trend-card">
|
<view class="trend-card">
|
||||||
<view class="chart-section-title">扫码趋势</view>
|
<view class="chart-section-title">扫码趋势</view>
|
||||||
<view class="bar-chart">
|
<view class="bar-chart">
|
||||||
<view v-for="item in trendData" :key="item.label" class="bar-item">
|
<view v-for="item in trendData" :key="item.label" class="bar-item">
|
||||||
<view class="bar-track">
|
<text class="bar-value">{{ item.value }}</text>
|
||||||
|
<view class="bar-track">
|
||||||
<view class="bar-fill" :style="{ height: item.percent + '%', backgroundColor: item.color }"></view>
|
<view class="bar-fill" :style="{ height: item.percent + '%', backgroundColor: item.color }"></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="bar-label">{{ item.label }}</text>
|
<text class="bar-label">{{ item.label }}</text>
|
||||||
@@ -67,16 +77,22 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="progress-card">
|
<view class="progress-card">
|
||||||
<view class="chart-section-title">异常处理</view>
|
<view class="responsibility-header">
|
||||||
<view v-for="item in exceptionData" :key="item.label" class="progress-row">
|
<view>
|
||||||
<view class="progress-info">
|
<text class="chart-section-title responsibility-title">不良责任订单</text>
|
||||||
<text class="progress-label">{{ item.label }}</text>
|
<text class="responsibility-subtitle">本月总计 {{ responsibilityOrderTotal }} 次 · 最近 5 条</text>
|
||||||
<text class="progress-value">{{ item.value }}%</text>
|
|
||||||
</view>
|
|
||||||
<view class="progress-track">
|
|
||||||
<view class="progress-fill" :style="{ width: item.value + '%', backgroundColor: item.color }"></view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="responsibilityOrders.length" class="reporter-list">
|
||||||
|
<view v-for="item in responsibilityOrders" :key="item.key" class="reporter-row">
|
||||||
|
<view>
|
||||||
|
<text class="reporter-name">订单:{{ item.order_no }}</text>
|
||||||
|
<text class="reporter-code">不良原因:{{ item.exception_reason || '未记录不良原因' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="reporter-count">{{ item.quantity }} 次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-else class="breakdown-empty">本月暂无不良责任订单</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -84,6 +100,8 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getUserInfo } from '@/api/member.js'
|
import { getUserInfo } from '@/api/member.js'
|
||||||
|
import { getUnreadNotificationCount } from '@/api/produce/notification.js'
|
||||||
|
import { getTodayDashboard } from '@/api/produce/data-entry.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -94,24 +112,19 @@
|
|||||||
levelName: '请先登录',
|
levelName: '请先登录',
|
||||||
enabled: true
|
enabled: true
|
||||||
},
|
},
|
||||||
|
unreadNotificationCount: 0,
|
||||||
|
responsibilityOrders: [],
|
||||||
|
responsibilityOrderTotal: 0,
|
||||||
summaries: [
|
summaries: [
|
||||||
{ label: '订单扫码', value: '128' },
|
{ label: '订单扫码', value: '0', type: 'primary' },
|
||||||
{ label: '整机扫码', value: '86' },
|
{ label: '整机记录', value: '0', type: 'success' },
|
||||||
{ label: '异常待处理', value: '12' }
|
{ label: '异常提报', value: '0', type: 'warning' },
|
||||||
|
{ label: '维修完成', value: '0', type: 'danger' }
|
||||||
],
|
],
|
||||||
trendData: [
|
trendData: [
|
||||||
{ label: '09', percent: 42, color: '#1677ff' },
|
{ label: '--:--', value: 0, percent: 0, color: '#1677ff' }
|
||||||
{ label: '10', percent: 68, color: '#35bfa3' },
|
|
||||||
{ label: '11', percent: 52, color: '#1677ff' },
|
|
||||||
{ label: '12', percent: 76, color: '#35bfa3' },
|
|
||||||
{ label: '13', percent: 45, color: '#1677ff' },
|
|
||||||
{ label: '14', percent: 88, color: '#35bfa3' }
|
|
||||||
],
|
|
||||||
exceptionData: [
|
|
||||||
{ label: '已处理', value: 72, color: '#19be6b' },
|
|
||||||
{ label: '待复核', value: 46, color: '#f7b733' },
|
|
||||||
{ label: '待处理', value: 28, color: '#e91f3f' }
|
|
||||||
],
|
],
|
||||||
|
dashboardUpdatedAt: '等待更新',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: '扫码',
|
title: '扫码',
|
||||||
@@ -119,7 +132,6 @@
|
|||||||
{ title: '订单模式', icon: 'order', bgColor: '#e91f3f', url: '/pages/entry/order' },
|
{ title: '订单模式', icon: 'order', bgColor: '#e91f3f', url: '/pages/entry/order' },
|
||||||
{ title: '一单多机', icon: 'grid-fill', bgColor: '#6b70e8', url: '/pages/entry/multi-machine' },
|
{ title: '一单多机', icon: 'grid-fill', bgColor: '#6b70e8', url: '/pages/entry/multi-machine' },
|
||||||
{ title: '整机模式', icon: 'scan', bgColor: '#1e9df2', url: '/pages/entry/machine' },
|
{ title: '整机模式', icon: 'scan', bgColor: '#1e9df2', url: '/pages/entry/machine' },
|
||||||
{ title: 'UTS 扫码原型', icon: 'scan', bgColor: '#1677ff', url: '/pages/scan/prototype' },
|
|
||||||
{ title: '异常处理', icon: 'warning-fill', bgColor: '#f7b733', url: '/pages/exception/index' },
|
{ title: '异常处理', icon: 'warning-fill', bgColor: '#f7b733', url: '/pages/exception/index' },
|
||||||
{ title: '维修处理', icon: 'setting-fill', bgColor: '#35bfa3', url: '/pages/repair/index' }
|
{ title: '维修处理', icon: 'setting-fill', bgColor: '#35bfa3', url: '/pages/repair/index' }
|
||||||
]
|
]
|
||||||
@@ -135,8 +147,15 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
computed: {
|
||||||
this.loadMemberInfo()
|
unreadNotificationText() {
|
||||||
|
return this.unreadNotificationCount > 99 ? '99+' : String(this.unreadNotificationCount)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.loadMemberInfo()
|
||||||
|
this.loadNotificationCount()
|
||||||
|
this.loadTodayDashboard()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async loadMemberInfo() {
|
async loadMemberInfo() {
|
||||||
@@ -163,6 +182,78 @@
|
|||||||
console.error('[scanapp] 获取首页会员信息失败', error)
|
console.error('[scanapp] 获取首页会员信息失败', error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loadNotificationCount() {
|
||||||
|
if (!this.isLoggedIn()) {
|
||||||
|
this.unreadNotificationCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getUnreadNotificationCount()
|
||||||
|
this.unreadNotificationCount = Number(data?.unread_count || 0)
|
||||||
|
} catch (error) {
|
||||||
|
this.unreadNotificationCount = 0
|
||||||
|
console.error('[app] 获取未读通知数量失败', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadTodayDashboard() {
|
||||||
|
if (!this.isLoggedIn()) {
|
||||||
|
this.summaries = [
|
||||||
|
{ label: '订单扫码', value: '0', type: 'primary' },
|
||||||
|
{ label: '整机记录', value: '0', type: 'success' },
|
||||||
|
{ label: '异常提报', value: '0', type: 'warning' },
|
||||||
|
{ label: '维修完成', value: '0', type: 'danger' }
|
||||||
|
]
|
||||||
|
this.responsibilityOrders = []
|
||||||
|
this.responsibilityOrderTotal = 0
|
||||||
|
this.trendData = [{ label: '--:--', value: 0, percent: 0, color: '#1677ff' }]
|
||||||
|
this.dashboardUpdatedAt = '登录后查看'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getTodayDashboard()
|
||||||
|
const summary = data?.summary || {}
|
||||||
|
this.summaries = [
|
||||||
|
{ label: '订单扫码', value: String(summary.order_scan || 0), type: 'primary', details: this.createBreakdownDetails([...(data?.order_breakdown || []), ...(data?.repair_breakdown || [])]) },
|
||||||
|
{ label: '整机记录', value: String(summary.product_scan || 0), type: 'success', details: this.createBreakdownDetails(data?.product_breakdown) },
|
||||||
|
{ label: '异常提报', value: String(summary.exception || 0), type: 'warning' },
|
||||||
|
{ label: '维修完成', value: String(summary.repair || 0), type: 'danger' }
|
||||||
|
]
|
||||||
|
this.responsibilityOrders = (Array.isArray(data?.responsibility_orders) ? data.responsibility_orders : []).map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
key: `${item.order_no || 'unknown'}-${index}`,
|
||||||
|
quantity: Number(item.quantity || 0)
|
||||||
|
}))
|
||||||
|
this.responsibilityOrderTotal = Number(data?.responsibility_order_total || 0)
|
||||||
|
const trend = Array.isArray(data?.trend) ? data.trend : []
|
||||||
|
const maxTrend = Math.max(1, ...trend.map((item) => Number(item.value || 0)))
|
||||||
|
this.trendData = trend.map((item, index) => ({
|
||||||
|
label: item.label || `时段${index + 1}`,
|
||||||
|
value: Number(item.value || 0),
|
||||||
|
percent: Math.round((Number(item.value || 0) / maxTrend) * 100),
|
||||||
|
color: index % 2 === 0 ? '#1677ff' : '#35bfa3'
|
||||||
|
}))
|
||||||
|
this.dashboardUpdatedAt = data?.updated_at ? `更新于 ${this.formatDashboardTime(data.updated_at)}` : '刚刚更新'
|
||||||
|
} catch (error) {
|
||||||
|
this.dashboardUpdatedAt = '暂时无法更新'
|
||||||
|
console.error('[app] 获取首页今日数据失败', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatDashboardTime(timestamp) {
|
||||||
|
const date = new Date(Number(timestamp) * 1000)
|
||||||
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
},
|
||||||
|
createBreakdownDetails(items) {
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
const grouped = {}
|
||||||
|
list.forEach((item) => {
|
||||||
|
const key = `${item.process_id || 'none'}-${item.step_id || 'none'}`
|
||||||
|
if (!grouped[key]) grouped[key] = { key, name: `${item.process_name || '未记录工序'} / ${item.step_name || '未记录步骤'}`, quantity: 0 }
|
||||||
|
grouped[key].quantity += Number(item.quantity || 0)
|
||||||
|
})
|
||||||
|
return Object.values(grouped)
|
||||||
|
},
|
||||||
isLoggedIn() {
|
isLoggedIn() {
|
||||||
return Boolean(uni.getStorageSync('scanapp_token'))
|
return Boolean(uni.getStorageSync('scanapp_token'))
|
||||||
},
|
},
|
||||||
@@ -175,7 +266,7 @@
|
|||||||
}
|
}
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '需要登录',
|
title: '需要登录',
|
||||||
content: '登录后可访问个人资料',
|
content: '登录后可使用此功能',
|
||||||
confirmText: '去登录',
|
confirmText: '去登录',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
@@ -189,6 +280,9 @@
|
|||||||
openProfile() {
|
openProfile() {
|
||||||
this.requireLogin('/pages/profile/index')
|
this.requireLogin('/pages/profile/index')
|
||||||
},
|
},
|
||||||
|
openNotifications() {
|
||||||
|
this.requireLogin('/pages/notification/index')
|
||||||
|
},
|
||||||
handleMenuTap(item) {
|
handleMenuTap(item) {
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
@@ -240,6 +334,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-action {
|
.hero-action {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -250,6 +345,24 @@
|
|||||||
box-shadow: 0 12rpx 32rpx rgba(23, 35, 61, 0.08);
|
box-shadow: 0 12rpx 32rpx rgba(23, 35, 61, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -10rpx;
|
||||||
|
right: -12rpx;
|
||||||
|
min-width: 30rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
padding: 0 6rpx;
|
||||||
|
border: 3rpx solid #ffffff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #e91f3f;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.user-card {
|
.user-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -462,19 +575,52 @@
|
|||||||
|
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
margin-bottom: 22rpx;
|
margin-bottom: 22rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card {
|
.summary-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 112rpx;
|
min-height: 136rpx;
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
border-radius: 22rpx;
|
border-radius: 22rpx;
|
||||||
background: #f8fbff;
|
background: linear-gradient(145deg, #f8fbff 0%, #f3f7fd 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.has-details {
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-height: 188rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 20rpx;
|
||||||
|
bottom: 20rpx;
|
||||||
|
left: 0;
|
||||||
|
width: 6rpx;
|
||||||
|
border-radius: 0 6rpx 6rpx 0;
|
||||||
|
background: #1677ff;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.success::before {
|
||||||
|
background: #19be6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.warning::before {
|
||||||
|
background: #f7a928;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.danger::before {
|
||||||
|
background: #e91f3f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-value {
|
.summary-value {
|
||||||
@@ -484,6 +630,30 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.has-details .summary-main {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.success {
|
||||||
|
color: #19be6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.warning {
|
||||||
|
color: #f7a928;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.danger {
|
||||||
|
color: #e91f3f;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-label {
|
.summary-label {
|
||||||
margin-top: 8rpx;
|
margin-top: 8rpx;
|
||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
@@ -491,6 +661,138 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-card.has-details .summary-label {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-detail-list {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
padding-top: 10rpx;
|
||||||
|
border-top: 1rpx solid #eaf0f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7rpx 10rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-detail-row + .summary-detail-row {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-detail-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #5f6b7a;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-detail-value {
|
||||||
|
margin-left: 8rpx;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-header,
|
||||||
|
.breakdown-row,
|
||||||
|
.reporter-row,
|
||||||
|
.responsibility-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-title,
|
||||||
|
.breakdown-name,
|
||||||
|
.breakdown-value,
|
||||||
|
.breakdown-total,
|
||||||
|
.breakdown-empty,
|
||||||
|
.reporter-name,
|
||||||
|
.reporter-code,
|
||||||
|
.reporter-count,
|
||||||
|
.exception-status-title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-title {
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-total {
|
||||||
|
margin-left: 8rpx;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-list {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row {
|
||||||
|
padding: 10rpx 0;
|
||||||
|
border-bottom: 1rpx solid #eaf0f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #5f6b7a;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-value {
|
||||||
|
margin-left: 10rpx;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-empty {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: #9aa4b4;
|
||||||
|
font-size: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.trend-card,
|
.trend-card,
|
||||||
.progress-card {
|
.progress-card {
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
@@ -502,6 +804,45 @@
|
|||||||
margin-top: 20rpx;
|
margin-top: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reporter-list {
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reporter-row {
|
||||||
|
padding: 12rpx 0;
|
||||||
|
border-bottom: 1rpx solid #eaf0f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reporter-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reporter-name {
|
||||||
|
color: #5f6b7a;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reporter-code {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: #9aa4b4;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reporter-count {
|
||||||
|
color: #f7a928;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exception-status-title {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-section-title {
|
.chart-section-title {
|
||||||
margin-bottom: 22rpx;
|
margin-bottom: 22rpx;
|
||||||
color: #17233d;
|
color: #17233d;
|
||||||
@@ -509,6 +850,19 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.responsibility-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsibility-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.bar-chart {
|
.bar-chart {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -523,6 +877,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bar-value {
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
color: #5f6b7a;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.bar-track {
|
.bar-track {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -609,11 +970,20 @@
|
|||||||
|
|
||||||
.theme-dark .menu-item--active,
|
.theme-dark .menu-item--active,
|
||||||
.theme-dark .summary-card,
|
.theme-dark .summary-card,
|
||||||
|
.theme-dark .breakdown-card,
|
||||||
.theme-dark .trend-card,
|
.theme-dark .trend-card,
|
||||||
.theme-dark .progress-card {
|
.theme-dark .progress-card {
|
||||||
background: #1c2431;
|
background: #1c2431;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-dark .summary-detail-list {
|
||||||
|
border-color: #364152;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .summary-detail-row {
|
||||||
|
background: #222936;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-dark .user-account,
|
.theme-dark .user-account,
|
||||||
.theme-dark .menu-title,
|
.theme-dark .menu-title,
|
||||||
.theme-dark .workspace-title,
|
.theme-dark .workspace-title,
|
||||||
@@ -624,15 +994,43 @@
|
|||||||
color: #f2f5fa;
|
color: #f2f5fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .hero-subtitle,
|
.theme-dark .hero-subtitle,
|
||||||
.theme-dark .user-name,
|
.theme-dark .user-name,
|
||||||
.theme-dark .menu-desc,
|
.theme-dark .menu-desc,
|
||||||
.theme-dark .chart-subtitle,
|
.theme-dark .chart-subtitle,
|
||||||
|
.theme-dark .responsibility-subtitle,
|
||||||
.theme-dark .summary-label,
|
.theme-dark .summary-label,
|
||||||
.theme-dark .bar-label,
|
.theme-dark .bar-label,
|
||||||
.theme-dark .progress-label {
|
.theme-dark .progress-label {
|
||||||
color: #aeb9ca;
|
color: #aeb9ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-dark .breakdown-title,
|
||||||
|
.theme-dark .exception-status-title {
|
||||||
|
color: #f2f5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .breakdown-name,
|
||||||
|
.theme-dark .reporter-name {
|
||||||
|
color: #aeb9ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .breakdown-value {
|
||||||
|
color: #f2f5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .summary-detail-name {
|
||||||
|
color: #aeb9ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .summary-detail-value {
|
||||||
|
color: #f2f5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .breakdown-row,
|
||||||
|
.theme-dark .reporter-row {
|
||||||
|
border-color: #364152;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-dark .bar-track,
|
.theme-dark .bar-track,
|
||||||
.theme-dark .progress-track {
|
.theme-dark .progress-track {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<view class="notification-page" :class="`theme-${themeMode}`">
|
||||||
|
<view v-if="loading" class="loading-state">
|
||||||
|
<u-loading mode="circle" color="#1677ff" size="32" />
|
||||||
|
<text>正在加载通知</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="notifications.length === 0" class="empty-state">
|
||||||
|
<u-empty mode="message" text="暂无生产通知" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="notification-list">
|
||||||
|
<view
|
||||||
|
v-for="item in notifications"
|
||||||
|
:key="item.id"
|
||||||
|
class="notification-card"
|
||||||
|
:class="{ read: Number(item.status) === 1 }"
|
||||||
|
@tap="readNotification(item)"
|
||||||
|
>
|
||||||
|
<view class="notification-heading">
|
||||||
|
<view class="notification-title-wrap">
|
||||||
|
<text v-if="Number(item.status) === 0" class="unread-dot"></text>
|
||||||
|
<text class="notification-title">{{ item.title }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="notification-status">{{ Number(item.status) === 0 ? '未读' : '已读' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="notification-content">{{ item.content }}</text>
|
||||||
|
<text class="notification-time">{{ item.created_date || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getMemberNotifications, markNotificationRead } from '@/api/produce/notification.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
notifications: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.loadNotifications()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadNotifications() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const data = await getMemberNotifications({ page: 1, limit: 50 })
|
||||||
|
this.notifications = Array.isArray(data?.items) ? data.items : []
|
||||||
|
} catch (error) {
|
||||||
|
this.notifications = []
|
||||||
|
console.error('[app] 获取生产通知失败', error)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async readNotification(item) {
|
||||||
|
if (Number(item.status) === 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
item.status = 1
|
||||||
|
item.read_at = Math.floor(Date.now() / 1000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[app] 标记生产通知已读失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.notification-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #f4f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 360rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #7d8798;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state text {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-card {
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
padding: 26rpx 24rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 12rpx 30rpx rgba(23, 35, 61, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-card.read {
|
||||||
|
background: #fafbfc;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-heading,
|
||||||
|
.notification-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-dot {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e91f3f;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-status {
|
||||||
|
margin-left: 16rpx;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-card.read .notification-status {
|
||||||
|
color: #8b8f98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content,
|
||||||
|
.notification-time {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: #4d5870;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: #98a1b2;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<view class="metric-card">
|
<view class="metric-card">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">核心指标</text>
|
<text class="section-title">核心指标</text>
|
||||||
<text class="section-subtitle">近 30 天累计</text>
|
<text class="section-subtitle">本月累计</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="metric-grid">
|
<view class="metric-grid">
|
||||||
<view v-for="item in metrics" :key="item.label" class="metric-item">
|
<view v-for="item in metrics" :key="item.label" class="metric-item">
|
||||||
@@ -37,6 +37,20 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="process-card">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">工序步骤统计</text>
|
||||||
|
<text class="section-subtitle">本月 Top 10</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="processBreakdown.length" class="process-list">
|
||||||
|
<view v-for="item in processBreakdown" :key="item.key" class="process-row">
|
||||||
|
<text class="process-name">{{ item.name }}</text>
|
||||||
|
<text class="process-count">{{ item.quantity }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-else class="empty-text">本月暂无工序步骤记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="trend-card">
|
<view class="trend-card">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">个人趋势</text>
|
<text class="section-title">个人趋势</text>
|
||||||
@@ -52,6 +66,32 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="calendar-card">
|
||||||
|
<view class="calendar-title-row">
|
||||||
|
<view class="calendar-title"><u-icon name="list-dot" color="#1677ff" size="34" /><text>月度总表({{ calendarMonthLabel }})</text></view>
|
||||||
|
<view class="month-actions"><text @tap="changeMonth(-1)">‹</text><text @tap="changeMonth(1)">›</text></view>
|
||||||
|
</view>
|
||||||
|
<text class="calendar-hint">日历仅显示是否有数据,点击日期查看当天统计</text>
|
||||||
|
<view class="weekday-row"><text v-for="day in weekdays" :key="day">{{ day }}</text></view>
|
||||||
|
<view v-for="(week, index) in calendarWeeks" :key="index" class="calendar-week">
|
||||||
|
<view v-for="cell in week" :key="cell.date" class="calendar-cell" :class="{ muted: !cell.currentMonth, selected: cell.date === selectedDate, today: cell.isToday, hasData: cell.hasData }" @tap="selectCalendarDate(cell)">
|
||||||
|
<text>{{ cell.day }}</text><view v-if="cell.hasData" class="data-dot"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="day-detail">
|
||||||
|
<view class="day-detail-header"><text>当天明细</text><text>{{ selectedDate }}</text></view>
|
||||||
|
<view class="day-metrics">
|
||||||
|
<view><text>订单扫码</text><strong>{{ selectedDaySummary.order }}</strong></view>
|
||||||
|
<view><text>整机数量</text><strong>{{ selectedDaySummary.product }}</strong></view>
|
||||||
|
<view><text>总计</text><strong>{{ selectedDaySummary.total }}</strong></view>
|
||||||
|
</view>
|
||||||
|
<view v-if="selectedDaySummary.steps.length" class="day-step-list">
|
||||||
|
<view v-for="step in selectedDaySummary.steps" :key="step.key" class="day-step-row"><text>{{ step.name }}</text><strong>{{ step.quantity }}</strong></view>
|
||||||
|
</view>
|
||||||
|
<text v-else class="day-empty">当天暂无工序数据</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="record-card">
|
<view class="record-card">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">最近明细</text>
|
<text class="section-title">最近明细</text>
|
||||||
@@ -72,43 +112,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="split-card">
|
|
||||||
<view class="split-section">
|
|
||||||
<view class="section-header compact">
|
|
||||||
<text class="section-title">扫码类型占比</text>
|
|
||||||
</view>
|
|
||||||
<view v-for="item in typeRate" :key="item.label" class="progress-row">
|
|
||||||
<view class="progress-info">
|
|
||||||
<text class="progress-label">{{ item.label }}</text>
|
|
||||||
<text class="progress-value">{{ item.value }}%</text>
|
|
||||||
</view>
|
|
||||||
<view class="progress-track">
|
|
||||||
<view class="progress-fill" :style="{ width: item.value + '%', backgroundColor: item.color }"></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="split-section">
|
|
||||||
<view class="section-header compact">
|
|
||||||
<text class="section-title">异常统计</text>
|
|
||||||
</view>
|
|
||||||
<view v-for="item in exceptionData" :key="item.label" class="progress-row">
|
|
||||||
<view class="progress-info">
|
|
||||||
<text class="progress-label">{{ item.label }}</text>
|
|
||||||
<text class="progress-value">{{ item.value }}%</text>
|
|
||||||
</view>
|
|
||||||
<view class="progress-track">
|
|
||||||
<view class="progress-fill" :style="{ width: item.value + '%', backgroundColor: item.color }"></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getUserInfo } from '@/api/member.js'
|
import { getUserInfo } from '@/api/member.js'
|
||||||
import { getOrderRecentRecords, getOrderStatistics, getProductRecentRecords, getProductStatistics } from '@/api/produce/data-entry.js'
|
import { getExceptionStatistics, getOrderRecentRecords, getOrderStatistics, getProductRecentRecords, getProductStatistics, getRepairStatistics, getTodayDashboard } from '@/api/produce/data-entry.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -122,23 +131,24 @@ export default {
|
|||||||
level: '-'
|
level: '-'
|
||||||
},
|
},
|
||||||
metrics: [
|
metrics: [
|
||||||
{ label: '今日扫码', value: '0', type: 'primary' },
|
{ label: '订单数', value: '0', type: 'primary' }, { label: '整机数', value: '0', type: 'success' },
|
||||||
{ label: '本月累计', value: '0', type: 'success' },
|
{ label: '本月总计', value: '0', type: 'primary' }, { label: '异常提报', value: '0', type: 'warning' },
|
||||||
{ label: '成功率', value: '0%', type: 'success' },
|
{ label: '维修量', value: '0', type: 'warning' }, { label: '责任订单', value: '0', type: 'danger' }
|
||||||
{ label: '异常数', value: '0', type: 'danger' }
|
|
||||||
],
|
],
|
||||||
trendData: [],
|
trendData: [],
|
||||||
|
processBreakdown: [],
|
||||||
recentRecords: [],
|
recentRecords: [],
|
||||||
typeRate: [
|
weekdays: ['一', '二', '三', '四', '五', '六', '日'],
|
||||||
{ label: '订单模式', value: 0, color: '#2f6cf6' },
|
calendarMonth: new Date(),
|
||||||
{ label: '整机模式', value: 0, color: '#35bfa3' },
|
selectedDate: '',
|
||||||
{ label: '异常记录', value: 0, color: '#f7b733' }
|
calendarWeeks: [],
|
||||||
],
|
calendarData: {},
|
||||||
exceptionData: [
|
selectedDaySummary: { order: 0, product: 0, quantity: 0, total: 0, steps: [] },
|
||||||
{ label: '已处理', value: 0, color: '#19be6b' },
|
}
|
||||||
{ label: '待复核', value: 0, color: '#f7b733' },
|
},
|
||||||
{ label: '待处理', value: 0, color: '#e91f3f' }
|
computed: {
|
||||||
]
|
calendarMonthLabel() {
|
||||||
|
return `${this.calendarMonth.getFullYear()}-${String(this.calendarMonth.getMonth() + 1).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -151,13 +161,25 @@ export default {
|
|||||||
},
|
},
|
||||||
async loadPageData() {
|
async loadPageData() {
|
||||||
const userCode = this.getCurrentUserCode()
|
const userCode = this.getCurrentUserCode()
|
||||||
|
const today = new Date()
|
||||||
|
const dateText = (date) => date.toISOString().slice(0, 10)
|
||||||
|
const todayDate = dateText(today)
|
||||||
|
this.selectedDate = todayDate
|
||||||
|
this.buildCalendar()
|
||||||
|
const monthDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`
|
||||||
try {
|
try {
|
||||||
const [profile, orderStats, productStats, orderRecent, productRecent] = await Promise.all([
|
const [profile, orderStats, productStats, repairStats, exceptionStats, dashboard, todayOrderStats, todayProductStats, todayRepairStats, orderRecent, productRecent] = await Promise.all([
|
||||||
getUserInfo(),
|
getUserInfo(),
|
||||||
getOrderStatistics({ user_code: userCode }),
|
getOrderStatistics({ user_code: userCode, start_date: monthDate, end_date: todayDate }),
|
||||||
getProductStatistics({ user_code: userCode }),
|
getProductStatistics({ user_code: userCode, start_date: monthDate, end_date: todayDate }),
|
||||||
getOrderRecentRecords({ user_code: userCode, limit: 3 }),
|
getRepairStatistics({ user_code: userCode, start_date: monthDate, end_date: todayDate }),
|
||||||
getProductRecentRecords({ user_code: userCode, limit: 3 })
|
getExceptionStatistics({ user_code: userCode, start_date: monthDate, end_date: todayDate }),
|
||||||
|
getTodayDashboard(),
|
||||||
|
getOrderStatistics({ user_code: userCode, start_date: todayDate, end_date: todayDate }),
|
||||||
|
getProductStatistics({ user_code: userCode, start_date: todayDate, end_date: todayDate }),
|
||||||
|
getRepairStatistics({ user_code: userCode, start_date: todayDate, end_date: todayDate }),
|
||||||
|
getOrderRecentRecords({ user_code: userCode, start_date: monthDate, end_date: todayDate, limit: 3 }),
|
||||||
|
getProductRecentRecords({ user_code: userCode, start_date: monthDate, end_date: todayDate, limit: 3 })
|
||||||
])
|
])
|
||||||
|
|
||||||
this.profile = {
|
this.profile = {
|
||||||
@@ -169,16 +191,42 @@ export default {
|
|||||||
level: Number(profile.enabled) === 0 ? '已停用' : '稳定输出'
|
level: Number(profile.enabled) === 0 ? '已停用' : '稳定输出'
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayTotal = Number(orderStats?.today_total || 0) + Number(productStats?.today_total || 0)
|
const repairTotal = Number(repairStats?.completed_today || 0)
|
||||||
const successTotal = Number(orderStats?.success_total || 0) + Number(productStats?.success_total || 0)
|
const exceptionTotal = Number(exceptionStats?.pending || 0) + Number(exceptionStats?.review || 0) + Number(exceptionStats?.done || 0)
|
||||||
const failedTotal = Number(orderStats?.failed_total || 0) + Number(productStats?.failed_total || 0)
|
const monthScanTotal = Number(orderStats?.today_total || 0) + Number(productStats?.today_total || 0)
|
||||||
const successRate = todayTotal > 0 ? ((successTotal / todayTotal) * 100).toFixed(1) : '0.0'
|
const todayTotal = Number(todayOrderStats?.today_total || 0) + Number(todayProductStats?.today_total || 0)
|
||||||
|
this.selectedDaySummary = this.makeDaySummary(todayOrderStats, todayProductStats, todayRepairStats)
|
||||||
|
const responsibilityTotal = Number(dashboard?.responsibility_order_total || 0)
|
||||||
this.metrics = [
|
this.metrics = [
|
||||||
{ label: '今日扫码', value: String(todayTotal), type: 'primary' },
|
{ label: '订单数', value: String(orderStats?.today_total || 0), type: 'primary' },
|
||||||
{ label: '本月累计', value: String(todayTotal), type: 'success' },
|
{ label: '整机数', value: String(productStats?.today_total || 0), type: 'success' },
|
||||||
{ label: '成功率', value: `${successRate}%`, type: 'success' },
|
{ label: '本月总计', value: String(monthScanTotal), type: 'primary' },
|
||||||
{ label: '异常数', value: String(failedTotal), type: 'danger' }
|
{ label: '异常提报', value: String(exceptionTotal), type: 'warning' },
|
||||||
|
{ label: '维修量', value: String(repairTotal), type: 'warning' },
|
||||||
|
{ label: '责任订单', value: String(responsibilityTotal), type: 'danger' }
|
||||||
]
|
]
|
||||||
|
const breakdown = [...(orderStats?.process_breakdown || []), ...(productStats?.process_breakdown || []), ...(repairStats?.process_breakdown || [])]
|
||||||
|
const grouped = {}
|
||||||
|
breakdown.forEach((item) => {
|
||||||
|
const key = `${item.process_id || 'none'}-${item.step_id || 'none'}`
|
||||||
|
if (!grouped[key]) grouped[key] = { key, name: `${item.process_name || '未记录工序'} / ${item.step_name || '未记录步骤'}`, quantity: 0 }
|
||||||
|
grouped[key].quantity += Number(item.quantity || 0)
|
||||||
|
})
|
||||||
|
this.processBreakdown = Object.values(grouped).sort((a, b) => b.quantity - a.quantity).slice(0, 10)
|
||||||
|
const trendMap = {}
|
||||||
|
;[...(orderStats?.daily_trend || []), ...(productStats?.daily_trend || [])].forEach((item) => {
|
||||||
|
trendMap[item.date] = (trendMap[item.date] || 0) + Number(item.quantity || 0)
|
||||||
|
})
|
||||||
|
const trendDates = Object.keys(trendMap).sort().slice(-7)
|
||||||
|
const maxTrend = Math.max(1, ...trendDates.map((date) => trendMap[date]))
|
||||||
|
this.trendData = trendDates.map((date, index) => ({
|
||||||
|
label: date.slice(5),
|
||||||
|
value: trendMap[date],
|
||||||
|
percent: Math.round(trendMap[date] / maxTrend * 100),
|
||||||
|
color: index % 2 ? '#35bfa3' : '#2f6cf6'
|
||||||
|
}))
|
||||||
|
this.calendarData = trendMap
|
||||||
|
this.buildCalendar()
|
||||||
|
|
||||||
const orderItems = Array.isArray(orderRecent?.items) ? orderRecent.items : []
|
const orderItems = Array.isArray(orderRecent?.items) ? orderRecent.items : []
|
||||||
const productItems = Array.isArray(productRecent?.items) ? productRecent.items : []
|
const productItems = Array.isArray(productRecent?.items) ? productRecent.items : []
|
||||||
@@ -192,20 +240,70 @@ export default {
|
|||||||
count: item.quantity ? `+${item.quantity}` : '1'
|
count: item.quantity ? `+${item.quantity}` : '1'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
this.typeRate = [
|
|
||||||
{ label: '订单模式', value: orderItems.length, color: '#2f6cf6' },
|
|
||||||
{ label: '整机模式', value: productItems.length, color: '#35bfa3' },
|
|
||||||
{ label: '异常记录', value: failedTotal, color: '#f7b733' }
|
|
||||||
]
|
|
||||||
|
|
||||||
this.exceptionData = [
|
|
||||||
{ label: '已处理', value: successTotal, color: '#19be6b' },
|
|
||||||
{ label: '待复核', value: failedTotal > 0 ? Math.min(failedTotal, 18) : 0, color: '#f7b733' },
|
|
||||||
{ label: '待处理', value: failedTotal > 18 ? failedTotal - 18 : 0, color: '#e91f3f' }
|
|
||||||
]
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[scanapp] 统计页数据加载失败', error)
|
console.error('[scanapp] 统计页数据加载失败', error)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
formatDate(date) {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
},
|
||||||
|
buildCalendar() {
|
||||||
|
const year = this.calendarMonth.getFullYear()
|
||||||
|
const month = this.calendarMonth.getMonth()
|
||||||
|
const firstDay = (new Date(year, month, 1).getDay() + 6) % 7
|
||||||
|
const cells = []
|
||||||
|
for (let index = 0; index < 42; index++) {
|
||||||
|
const date = new Date(year, month, index - firstDay + 1)
|
||||||
|
const value = this.formatDate(date)
|
||||||
|
cells.push({ date: value, day: date.getDate(), currentMonth: date.getMonth() === month, isToday: value === this.formatDate(new Date()), hasData: Boolean(this.calendarData[value]) })
|
||||||
|
}
|
||||||
|
this.calendarWeeks = Array.from({ length: 6 }, (_, index) => cells.slice(index * 7, index * 7 + 7))
|
||||||
|
},
|
||||||
|
async changeMonth(offset) {
|
||||||
|
this.calendarMonth = new Date(this.calendarMonth.getFullYear(), this.calendarMonth.getMonth() + offset, 1)
|
||||||
|
const year = this.calendarMonth.getFullYear()
|
||||||
|
const month = this.calendarMonth.getMonth() + 1
|
||||||
|
const startDate = `${year}-${String(month).padStart(2, '0')}-01`
|
||||||
|
const endDate = this.formatDate(new Date(year, month, 0))
|
||||||
|
try {
|
||||||
|
const [order, product] = await Promise.all([
|
||||||
|
getOrderStatistics({ user_code: this.getCurrentUserCode(), start_date: startDate, end_date: endDate }),
|
||||||
|
getProductStatistics({ user_code: this.getCurrentUserCode(), start_date: startDate, end_date: endDate })
|
||||||
|
])
|
||||||
|
this.calendarData = [...(order?.daily_trend || []), ...(product?.daily_trend || [])].reduce((map, item) => ({ ...map, [item.date]: true }), {})
|
||||||
|
} catch (error) {
|
||||||
|
this.calendarData = {}
|
||||||
|
}
|
||||||
|
this.buildCalendar()
|
||||||
|
const firstCell = this.calendarWeeks.flat().find((cell) => cell.currentMonth)
|
||||||
|
if (firstCell) this.selectCalendarDate(firstCell)
|
||||||
|
},
|
||||||
|
async selectCalendarDate(cell) {
|
||||||
|
if (!cell.currentMonth) return
|
||||||
|
this.selectedDate = cell.date
|
||||||
|
try {
|
||||||
|
const [order, product, repair] = await Promise.all([
|
||||||
|
getOrderStatistics({ user_code: this.getCurrentUserCode(), start_date: cell.date, end_date: cell.date }),
|
||||||
|
getProductStatistics({ user_code: this.getCurrentUserCode(), start_date: cell.date, end_date: cell.date }),
|
||||||
|
getRepairStatistics({ user_code: this.getCurrentUserCode(), start_date: cell.date, end_date: cell.date })
|
||||||
|
])
|
||||||
|
this.selectedDaySummary = this.makeDaySummary(order, product, repair)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[scanapp] 日期统计加载失败', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeDaySummary(order, product, repair) {
|
||||||
|
const orderTotal = Number(order?.today_total || 0)
|
||||||
|
const productTotal = Number(product?.today_total || 0)
|
||||||
|
const repairTotal = Number(repair?.completed_today || 0)
|
||||||
|
const rows = [...(order?.process_breakdown || []), ...(product?.process_breakdown || []), ...(repair?.process_breakdown || [])]
|
||||||
|
const grouped = {}
|
||||||
|
rows.forEach((item) => {
|
||||||
|
const key = `${item.process_id || 'none'}-${item.step_id || 'none'}`
|
||||||
|
if (!grouped[key]) grouped[key] = { key, name: `${item.process_name || '未记录工序'} / ${item.step_name || '未记录步骤'}`, quantity: 0 }
|
||||||
|
grouped[key].quantity += Number(item.quantity || 0)
|
||||||
|
})
|
||||||
|
return { order: orderTotal, product: productTotal, quantity: productTotal, total: orderTotal + productTotal + repairTotal, steps: Object.values(grouped).sort((a, b) => b.quantity - a.quantity) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,10 +319,11 @@ export default {
|
|||||||
|
|
||||||
.hero-card,
|
.hero-card,
|
||||||
.profile-card,
|
.profile-card,
|
||||||
|
.calendar-card,
|
||||||
.metric-card,
|
.metric-card,
|
||||||
.trend-card,
|
.trend-card,
|
||||||
.record-card,
|
.record-card,
|
||||||
.split-card {
|
.process-card {
|
||||||
margin-top: 18rpx;
|
margin-top: 18rpx;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
border-radius: 22rpx;
|
border-radius: 22rpx;
|
||||||
@@ -232,6 +331,179 @@ export default {
|
|||||||
box-shadow: 0 10rpx 26rpx rgba(23, 35, 61, 0.05);
|
box-shadow: 0 10rpx 26rpx rgba(23, 35, 61, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-title-row,
|
||||||
|
.calendar-title,
|
||||||
|
.month-actions,
|
||||||
|
.weekday-row,
|
||||||
|
.calendar-week,
|
||||||
|
.day-detail-header,
|
||||||
|
.day-metrics {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-title-row,
|
||||||
|
.day-detail-header {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-title {
|
||||||
|
gap: 12rpx;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-actions text {
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f5f8fd;
|
||||||
|
color: #7d8798;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-row,
|
||||||
|
.calendar-week {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-row {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: #9aa4b4;
|
||||||
|
font-size: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-row text,
|
||||||
|
.calendar-cell {
|
||||||
|
width: 13.2%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-week {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell {
|
||||||
|
position: relative;
|
||||||
|
padding: 18rpx 0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
color: #303949;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.muted {
|
||||||
|
color: #c4cad5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.hasData::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8rpx;
|
||||||
|
left: 50%;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #19be6b;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.selected {
|
||||||
|
background: #2f80ed;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 6rpx 14rpx rgba(47, 128, 237, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.selected.hasData::after {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-detail {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border: 1rpx solid #dceafd;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fbfdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-detail-header text:first-child {
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-detail-header text:last-child {
|
||||||
|
color: #7d8798;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-metrics {
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-metrics view {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14rpx 6rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f0f6ff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-metrics view:nth-child(2) { background: #f2fbea; }
|
||||||
|
.day-metrics view:nth-child(3) { background: #fff7e6; }
|
||||||
|
.day-metrics text,
|
||||||
|
.day-metrics strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-metrics text {
|
||||||
|
color: #7d8798;
|
||||||
|
font-size: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-metrics strong {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #303949;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-empty {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: #9aa4b4;
|
||||||
|
font-size: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-step-list {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
border-top: 1rpx solid #e7edf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-step-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12rpx 4rpx;
|
||||||
|
border-bottom: 1rpx solid #e7edf6;
|
||||||
|
color: #526078;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-step-row strong {
|
||||||
|
color: #2f6cf6;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-card {
|
.hero-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -370,7 +642,7 @@ export default {
|
|||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +671,10 @@ export default {
|
|||||||
color: #e91f3f;
|
color: #e91f3f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-value.warning {
|
||||||
|
color: #f7a928;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-label {
|
.metric-label {
|
||||||
margin-top: 6rpx;
|
margin-top: 6rpx;
|
||||||
color: #7d8798;
|
color: #7d8798;
|
||||||
@@ -450,6 +726,37 @@ export default {
|
|||||||
border-bottom: 1rpx solid #eef1f6;
|
border-bottom: 1rpx solid #eef1f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.process-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-bottom: 1rpx solid #eef1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-name {
|
||||||
|
color: #4d5b70;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-count {
|
||||||
|
color: #2f6cf6;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
display: block;
|
||||||
|
padding: 20rpx 0 6rpx;
|
||||||
|
color: #9aa4b4;
|
||||||
|
font-size: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.record-item:last-child {
|
.record-item:last-child {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
@@ -504,74 +811,29 @@ export default {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split-card {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-section {
|
|
||||||
padding: 20rpx;
|
|
||||||
border-radius: 18rpx;
|
|
||||||
background: #f8fbff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-row {
|
|
||||||
margin-top: 14rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-label,
|
|
||||||
.progress-value {
|
|
||||||
color: #7d8798;
|
|
||||||
font-size: 20rpx;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-value {
|
|
||||||
color: #17233d;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-track {
|
|
||||||
height: 14rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
background: #e9edf4;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
}
|
|
||||||
.theme-dark.statistics-page {
|
.theme-dark.statistics-page {
|
||||||
background: #151923;
|
background: #151923;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .hero-card,
|
.theme-dark .hero-card,
|
||||||
.theme-dark .profile-card,
|
.theme-dark .profile-card,
|
||||||
|
.theme-dark .calendar-card,
|
||||||
.theme-dark .metric-card,
|
.theme-dark .metric-card,
|
||||||
.theme-dark .trend-card,
|
.theme-dark .trend-card,
|
||||||
.theme-dark .record-card,
|
.theme-dark .record-card,
|
||||||
.theme-dark .split-card {
|
.theme-dark .process-card {
|
||||||
border-color: #364152;
|
border-color: #364152;
|
||||||
background: #222936;
|
background: #222936;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .metric-item,
|
.theme-dark .metric-item,
|
||||||
.theme-dark .split-section,
|
|
||||||
.theme-dark .profile-chip {
|
.theme-dark .profile-chip {
|
||||||
background: #1c2431;
|
background: #1c2431;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .profile-name,
|
.theme-dark .profile-name,
|
||||||
|
.theme-dark .calendar-title,
|
||||||
.theme-dark .section-title,
|
.theme-dark .section-title,
|
||||||
.theme-dark .metric-value,
|
.theme-dark .metric-value,
|
||||||
.theme-dark .record-main,
|
.theme-dark .record-main,
|
||||||
@@ -580,6 +842,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .profile-desc,
|
.theme-dark .profile-desc,
|
||||||
|
.theme-dark .calendar-hint,
|
||||||
.theme-dark .section-subtitle,
|
.theme-dark .section-subtitle,
|
||||||
.theme-dark .metric-label,
|
.theme-dark .metric-label,
|
||||||
.theme-dark .bar-label,
|
.theme-dark .bar-label,
|
||||||
@@ -590,16 +853,54 @@ export default {
|
|||||||
color: #aeb9ca;
|
color: #aeb9ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .record-item,
|
.theme-dark .process-name,
|
||||||
.theme-dark .progress-track {
|
.theme-dark .empty-text {
|
||||||
|
color: #aeb9ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .process-row {
|
||||||
border-color: #364152;
|
border-color: #364152;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .bar-track,
|
.theme-dark .record-item {
|
||||||
.theme-dark .progress-track {
|
border-color: #364152;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .bar-track {
|
||||||
background: #1c2431;
|
background: #1c2431;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-dark .calendar-hint,
|
||||||
|
.theme-dark .day-detail {
|
||||||
|
background: #1c2431;
|
||||||
|
border-color: #364152;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .calendar-cell {
|
||||||
|
color: #f2f5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .calendar-cell.muted,
|
||||||
|
.theme-dark .day-detail-header text:last-child,
|
||||||
|
.theme-dark .day-metrics text,
|
||||||
|
.theme-dark .day-empty {
|
||||||
|
color: #aeb9ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .day-detail-header text:first-child,
|
||||||
|
.theme-dark .day-metrics strong {
|
||||||
|
color: #f2f5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .day-step-list,
|
||||||
|
.theme-dark .day-step-row {
|
||||||
|
border-color: #364152;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .day-step-row {
|
||||||
|
color: #aeb9ca;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-dark .profile-badge {
|
.theme-dark .profile-badge {
|
||||||
background: rgba(47, 108, 246, 0.2);
|
background: rgba(47, 108, 246, 0.2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -400,6 +400,33 @@ const matchSpecificRule = (rule: ProduceDataEntrySpecificRule, valueMap: Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasExistingRecordForRule = (rule: ProduceDataEntrySpecificRule) => {
|
||||||
|
const matchField = rule.match_field || 'process_order_no'
|
||||||
|
const currentValue = String(({
|
||||||
|
process_order_no: processOrderNo.value,
|
||||||
|
order_no: processOrderNo.value,
|
||||||
|
product_code: productCode.value,
|
||||||
|
user_code: userCode.value,
|
||||||
|
} as Record<string, string>)[matchField] || '').trim()
|
||||||
|
if (!currentValue) return false
|
||||||
|
const matched = recentRecords.value.some((record) => {
|
||||||
|
const recordValue = String((record as Record<string, any>)[matchField] || (matchField === 'order_no' ? record.process_order_no : '')).trim()
|
||||||
|
return recordValue === currentValue
|
||||||
|
})
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDuplicateRuleMessage = (rule: ProduceDataEntrySpecificRule) => {
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
order_no: '加工单号',
|
||||||
|
process_order_no: '加工单号',
|
||||||
|
product_code: '商品编码',
|
||||||
|
user_code: '用户编码',
|
||||||
|
}
|
||||||
|
const field = rule.match_field || 'process_order_no'
|
||||||
|
return `${fieldLabels[field] || '当前数据'}已录入,当前规则不允许重复扫码`
|
||||||
|
}
|
||||||
|
|
||||||
const validateSpecificRules = () => {
|
const validateSpecificRules = () => {
|
||||||
if (!specificRules.value.length) {
|
if (!specificRules.value.length) {
|
||||||
return true
|
return true
|
||||||
@@ -415,11 +442,11 @@ const validateSpecificRules = () => {
|
|||||||
if ((rule.apply_mode || 'order') !== 'machine' && rule.apply_mode !== 'both') {
|
if ((rule.apply_mode || 'order') !== 'machine' && rule.apply_mode !== 'both') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context)
|
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context) && hasExistingRecordForRule(rule)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (blockedRule) {
|
if (blockedRule) {
|
||||||
ElMessage.warning(blockedRule.rule_name || '当前整机数据命中特定规则,禁止提交')
|
ElMessage.warning(getDuplicateRuleMessage(blockedRule))
|
||||||
const matchField = blockedRule.match_field || 'process_order_no'
|
const matchField = blockedRule.match_field || 'process_order_no'
|
||||||
if (matchField === 'product_code') {
|
if (matchField === 'product_code') {
|
||||||
focusProductCodeInput()
|
focusProductCodeInput()
|
||||||
@@ -484,12 +511,13 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitEntry = async () => {
|
const submitEntry = async () => {
|
||||||
|
if (submitting.value) return
|
||||||
if (!validateBeforeSubmit()) return
|
if (!validateBeforeSubmit()) return
|
||||||
const passed = await runUserCodeCheck(userCode.value)
|
const passed = await runUserCodeCheck(userCode.value)
|
||||||
if (!passed) { emit('play-error'); nextTick(() => focusUserCodeInput()); return }
|
if (!passed) { emit('play-error'); nextTick(() => focusUserCodeInput()); return }
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const result = await submitProductRecord({
|
const payload = {
|
||||||
process_order_no: processOrderNo.value.trim(),
|
process_order_no: processOrderNo.value.trim(),
|
||||||
product_code: productCode.value.trim(),
|
product_code: productCode.value.trim(),
|
||||||
quantity: Number(quantity.value) || 1,
|
quantity: Number(quantity.value) || 1,
|
||||||
@@ -497,7 +525,8 @@ const submitEntry = async () => {
|
|||||||
step_id: props.selectedStep,
|
step_id: props.selectedStep,
|
||||||
user_code: userCode.value.trim(),
|
user_code: userCode.value.trim(),
|
||||||
scan_result: 1,
|
scan_result: 1,
|
||||||
})
|
}
|
||||||
|
const result = await submitProductRecord(payload)
|
||||||
if (Number(result?.code) !== 0) throw new Error(result?.msg || '录入失败')
|
if (Number(result?.code) !== 0) throw new Error(result?.msg || '录入失败')
|
||||||
ElMessage.success('录入成功')
|
ElMessage.success('录入成功')
|
||||||
emit('play-success')
|
emit('play-success')
|
||||||
|
|||||||
Reference in New Issue
Block a user