完善生产查询与登录权限配置
This commit is contained in:
@@ -62,8 +62,3 @@ Auto-evolution workflow:
|
||||
- 插件相关前端内容必须先修改并核对 `backend/plugin/{plugin}/resource/template/admin` 下的模板源文件;模板源修改完成后,必须同步修改对应的 `frontend/admin/src/apps/{plugin}` 运行目录,并核对两者实现一致
|
||||
- 避免将未更改的内容总结为"文件其余部分保持不变"
|
||||
|
||||
# 本地项目与版本控制
|
||||
|
||||
- 本项目为本地项目,不使用 Git 进行状态检查、差异比较、提交或任何其他操作。
|
||||
- 后续回答、验证命令和开发说明中不出现 Git 或 Git 命令。
|
||||
- 如需确认文件改动,以实际文件内容、语法检查、自动化测试或运行时验证为准。
|
||||
|
||||
@@ -59,6 +59,8 @@ class AuthService extends BaseService
|
||||
|
||||
// 更新登录信息
|
||||
$this->updateLoginInfo($member);
|
||||
$channel = strtolower((string)request()->header('channel', 'pc'));
|
||||
$clientType = $channel === 'app' ? 'app' : 'web';
|
||||
$userInfo = [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
@@ -66,13 +68,14 @@ class AuthService extends BaseService
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'client' => 'web',
|
||||
'access_exp' => 7200,
|
||||
'client' => $clientType,
|
||||
'access_exp' => 3600,
|
||||
'refresh_exp' => 604800,
|
||||
];
|
||||
// 使用新的 JwtToken 生成 token
|
||||
$jwt = new JwtToken();
|
||||
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
|
||||
$tokenObj = $jwt->generate((string)$member->id, $clientType, $userInfo);
|
||||
$userInfo['access_exp'] = $tokenObj->expiresIn;
|
||||
$token = [
|
||||
'access_token' => $tokenObj->accessToken,
|
||||
'refresh_token' => $tokenObj->refreshToken,
|
||||
@@ -110,6 +113,8 @@ class AuthService extends BaseService
|
||||
$this->updateLoginInfo($member);
|
||||
|
||||
// 生成token
|
||||
$channel = strtolower((string)request()->header('channel', 'pc'));
|
||||
$clientType = $channel === 'app' ? 'app' : 'web';
|
||||
$userInfo = [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
@@ -117,13 +122,14 @@ class AuthService extends BaseService
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'client' => 'web',
|
||||
'access_exp' => 7200,
|
||||
'client' => $clientType,
|
||||
'access_exp' => 3600,
|
||||
'refresh_exp' => 604800,
|
||||
];
|
||||
// 使用新的 JwtToken 生成 token
|
||||
$jwt = new JwtToken();
|
||||
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
|
||||
$tokenObj = $jwt->generate((string)$member->id, $clientType, $userInfo);
|
||||
$userInfo['access_exp'] = $tokenObj->expiresIn;
|
||||
$token = [
|
||||
'access_token' => $tokenObj->accessToken,
|
||||
'refresh_token' => $tokenObj->refreshToken,
|
||||
@@ -702,4 +708,4 @@ HTML;
|
||||
throw new \Exception('登录已过期', 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
use app\model\system\Config;
|
||||
use UnexpectedValueException;
|
||||
|
||||
class JwtToken
|
||||
@@ -126,7 +127,7 @@ class JwtToken
|
||||
$jti = $this->generateJti();
|
||||
|
||||
// 生成 Access Token
|
||||
$accessTtl = $this->config['ttl']['access'] ?? 7200;
|
||||
$accessTtl = $this->getConfiguredTtl('access', $clientType);
|
||||
$accessPayload = $this->buildPayload(
|
||||
id: $id,
|
||||
client: $clientType,
|
||||
@@ -137,7 +138,7 @@ class JwtToken
|
||||
$accessToken = $this->makeToken($accessPayload);
|
||||
|
||||
// 生成 Refresh Token
|
||||
$refreshTtl = $this->config['ttl']['refresh'] ?? 604800;
|
||||
$refreshTtl = $this->getConfiguredTtl('refresh', $clientType);
|
||||
$refreshJti = $this->generateJti();
|
||||
$refreshPayload = $this->buildPayload(
|
||||
id: $id,
|
||||
@@ -156,7 +157,7 @@ class JwtToken
|
||||
'extra' => $extraData,
|
||||
'login_mode' => $loginMode,
|
||||
'created_at' => time(),
|
||||
], $accessTtl);
|
||||
], max($accessTtl, $refreshTtl));
|
||||
|
||||
// 创建 Token 对象
|
||||
$fullPayload = array_merge($accessPayload, ['id' => $id]);
|
||||
@@ -217,7 +218,7 @@ class JwtToken
|
||||
$loginMode = $this->config['login_mode'] ?? $oldTokenInfo['login_mode'] ?? LoginMode::MULTI->value;
|
||||
|
||||
// 将旧 access_token 和 refresh_token 加入黑名单
|
||||
$blacklistTtl = $this->config['ttl']['refresh'] ?? 604800;
|
||||
$blacklistTtl = $this->getConfiguredTtl('refresh', $clientType);
|
||||
if ($oldAccessJti !== '') {
|
||||
$this->blacklistStorage->add($oldAccessJti, $blacklistTtl);
|
||||
$this->tokenStorage->delete($oldAccessJti);
|
||||
@@ -288,7 +289,7 @@ class JwtToken
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessTtl = $this->config['ttl']['access'] ?? 7200;
|
||||
$accessTtl = $this->getConfiguredTtl('access', $payload['client'] ?? '');
|
||||
$this->currentToken = Token::create($accessToken, '', $accessTtl, $payload);
|
||||
|
||||
return $this->currentToken;
|
||||
@@ -739,6 +740,33 @@ class JwtToken
|
||||
/**
|
||||
* 构建 Token 载荷
|
||||
*/
|
||||
protected function getConfiguredTtl(string $type, string $clientType): int
|
||||
{
|
||||
$defaults = $this->config['ttl'] ?? ['access' => 3600, 'refresh' => 604800];
|
||||
$default = (int)($defaults[$type] ?? ($type === 'access' ? 3600 : 604800));
|
||||
$content = Config::query()->where('code', 'auth_token_setting')->where('enabled', 1)->value('content');
|
||||
if (is_string($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
$content = json_last_error() === JSON_ERROR_NONE ? $decoded : null;
|
||||
}
|
||||
if (!is_array($content)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$client = strtolower($clientType);
|
||||
$key = match ($client) {
|
||||
'admin' => $type === 'refresh' ? 'admin_refresh_ttl' : 'admin_access_ttl',
|
||||
'app', 'mobile' => $type === 'refresh' ? 'app_refresh_ttl' : 'app_access_ttl',
|
||||
default => $type === 'refresh' ? 'web_refresh_ttl' : 'web_access_ttl',
|
||||
};
|
||||
if ($type === 'refresh' && !array_key_exists($key, $content)) {
|
||||
$key = 'refresh_ttl';
|
||||
}
|
||||
$value = (int)($content[$key] ?? $default);
|
||||
$minimum = $type === 'refresh' ? 300 : 60;
|
||||
return $value >= $minimum ? $value : $default;
|
||||
}
|
||||
|
||||
protected function buildPayload(
|
||||
string $id,
|
||||
string $client,
|
||||
@@ -749,8 +777,8 @@ class JwtToken
|
||||
{
|
||||
$now = time();
|
||||
$ttl = match ($type) {
|
||||
TokenType::ACCESS => $this->config['ttl']['access'] ?? 7200,
|
||||
TokenType::REFRESH => $this->config['ttl']['refresh'] ?? 604800,
|
||||
TokenType::ACCESS => $this->getConfiguredTtl('access', $client),
|
||||
TokenType::REFRESH => $this->getConfiguredTtl('refresh', $client),
|
||||
};
|
||||
|
||||
$payload = [
|
||||
|
||||
@@ -12,6 +12,7 @@ enum ClientType: string
|
||||
case API = 'api';
|
||||
case WEB = 'web';
|
||||
case MOBILE = 'mobile';
|
||||
case APP = 'app';
|
||||
case MINI = 'mini';
|
||||
|
||||
/**
|
||||
|
||||
@@ -679,13 +679,14 @@ final class DataEntryController extends Base
|
||||
|
||||
#[OA\Get(path: '/produce/query/records', summary: '查询生产记录', tags: ['produce-web-query'])]
|
||||
#[DataResponse(description: '查询成功')]
|
||||
#[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')]
|
||||
public function queryRecords(Request $request): Response
|
||||
{
|
||||
$recordType = trim((string) $request->input('record_type', 'scan'));
|
||||
$orderNo = trim((string) $request->input('order_no', ''));
|
||||
$keyword = trim((string) $request->input('keyword', ''));
|
||||
$userCode = trim((string) $request->input('user_code', ''));
|
||||
$processId = (int) $request->input('process_id', 0);
|
||||
$stepId = (int) $request->input('step_id', 0);
|
||||
$status = $request->input('status', '');
|
||||
$scanResult = $request->input('scan_result', '');
|
||||
$page = max(1, (int) $request->input('page', 1));
|
||||
@@ -707,6 +708,8 @@ final class DataEntryController extends Base
|
||||
if ($recordType === 'scan') {
|
||||
$query->when($orderNo !== '', fn ($builder) => $builder->where('order_no', $orderNo));
|
||||
$query->when($userCode !== '', fn ($builder) => $builder->where('user_code', $userCode));
|
||||
$query->when($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
|
||||
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
|
||||
$query->when($status !== '' && $status !== null, fn ($builder) => $builder->where('status_id', (int) $status));
|
||||
$query->when($scanResult !== '' && $scanResult !== null, fn ($builder) => $builder->where('scan_result', (int) $scanResult));
|
||||
$query->when($keyword !== '', fn ($builder) => $builder->where(function ($nested) use ($keyword) {
|
||||
@@ -721,6 +724,8 @@ final class DataEntryController extends Base
|
||||
} elseif ($recordType === 'product') {
|
||||
$query->when($orderNo !== '', fn ($builder) => $builder->where('process_order_no', $orderNo));
|
||||
$query->when($userCode !== '', fn ($builder) => $builder->where('user_code', $userCode));
|
||||
$query->when($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
|
||||
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
|
||||
$query->when($status !== '' && $status !== null, fn ($builder) => $builder->where('status_id', (int) $status));
|
||||
$query->when($scanResult !== '' && $scanResult !== null, fn ($builder) => $builder->where('scan_result', (int) $scanResult));
|
||||
$query->when($keyword !== '', fn ($builder) => $builder->where(function ($nested) use ($keyword) {
|
||||
@@ -736,6 +741,8 @@ final class DataEntryController extends Base
|
||||
} elseif ($recordType === 'exception') {
|
||||
$query->when($orderNo !== '', fn ($builder) => $builder->where('order_no', $orderNo));
|
||||
$query->when($userCode !== '', fn ($builder) => $builder->where('user_code', $userCode));
|
||||
$query->when($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
|
||||
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
|
||||
$query->when($status !== '' && $status !== null, fn ($builder) => $builder->where('status', (int) $status));
|
||||
$query->when($keyword !== '', fn ($builder) => $builder->where(function ($nested) use ($keyword) {
|
||||
$nested->where('order_no', 'like', "%{$keyword}%")
|
||||
@@ -751,6 +758,8 @@ final class DataEntryController extends Base
|
||||
$query->orderByDesc('reported_at')->orderByDesc('id');
|
||||
} else {
|
||||
$query->when($orderNo !== '', fn ($builder) => $builder->where('order_no', $orderNo));
|
||||
$query->when($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
|
||||
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
|
||||
$query->when($keyword !== '', fn ($builder) => $builder->where(function ($nested) use ($keyword) {
|
||||
$nested->where('repair_no', 'like', "%{$keyword}%")
|
||||
->orWhere('order_no', 'like', "%{$keyword}%")
|
||||
@@ -772,7 +781,6 @@ final class DataEntryController extends Base
|
||||
|
||||
#[OA\Get(path: '/produce/query/timeline', summary: '查询订单生产时间线', tags: ['produce-web-query'])]
|
||||
#[DataResponse(description: '查询成功')]
|
||||
#[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')]
|
||||
public function queryTimeline(Request $request): Response
|
||||
{
|
||||
$orderNo = trim((string) $request->input('order_no', ''));
|
||||
@@ -936,9 +944,12 @@ final class DataEntryController extends Base
|
||||
return Json::fail('用户编码不存在');
|
||||
}
|
||||
|
||||
$repairFlow = $this->getRepairFlowNodeNames();
|
||||
|
||||
try {
|
||||
$record = Db::transaction(function () use ($exceptionId, $repairResult, $categoryIds, $parts, $images, $operationFaultSteps, $responsibleOperators, $data, $member, $repairRecordService) {
|
||||
$record = Db::transaction(function () use ($exceptionId, $repairResult, $categoryIds, $parts, $images, $operationFaultSteps, $responsibleOperators, $data, $member, $repairRecordService, $repairFlow) {
|
||||
$exception = ExceptionRecord::query()
|
||||
->with(['process', 'step'])
|
||||
->lockForUpdate()
|
||||
->whereKey($exceptionId)
|
||||
->whereIn('status', [0, 1])
|
||||
@@ -1062,6 +1073,10 @@ final class DataEntryController extends Base
|
||||
'exception_reason' => (string) $exception->reason_name,
|
||||
'exception_issue' => (string) ($exception->issue_name ?? ''),
|
||||
'order_no' => (string) $exception->order_no,
|
||||
'process_id' => $repairFlow['process_id'],
|
||||
'step_id' => $repairFlow['step_id'],
|
||||
'process_name' => $repairFlow['process_name'],
|
||||
'step_name' => $repairFlow['step_name'],
|
||||
'category_id' => $repairResult === 'assembly_mismatch' ? $categoryIds[0] : $primaryFault?->category_id,
|
||||
'category_ids' => $repairResult === 'assembly_mismatch' ? $categoryIds : null,
|
||||
'fault_description_id' => $primaryFault?->id,
|
||||
@@ -1316,8 +1331,8 @@ final class DataEntryController extends Base
|
||||
'record_type_name' => '维修记录',
|
||||
'order_no' => (string) $item->order_no,
|
||||
'product_code' => (string) $item->product_code,
|
||||
'process_name' => $this->getRepairFlowNodeNames()['process_name'],
|
||||
'step_name' => $this->getRepairFlowNodeNames()['step_name'],
|
||||
'process_name' => (string) ($item->process_name ?: '历史数据未记录'),
|
||||
'step_name' => (string) ($item->step_name ?: '历史数据未记录'),
|
||||
'status_name' => '已维修',
|
||||
'result_name' => (string) $item->repair_result_name,
|
||||
'operator' => (string) $item->operator,
|
||||
@@ -1349,8 +1364,10 @@ final class DataEntryController extends Base
|
||||
$step = $stepId > 0 ? ProcessStep::query()->withTrashed()->find($stepId) : null;
|
||||
|
||||
return $this->repairFlowCache = [
|
||||
'process_name' => (string) ($process?->process_name ?? ''),
|
||||
'step_name' => (string) ($step?->step_name ?? ''),
|
||||
'process_id' => $processId > 0 ? $processId : null,
|
||||
'step_id' => $stepId > 0 ? $stepId : null,
|
||||
'process_name' => (string) ($process?->process_name ?? '维修'),
|
||||
'step_name' => (string) ($step?->step_name ?? '维修'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ final class DataEntryController
|
||||
|
||||
#[OA\Get(path: '/produce/query/records', summary: 'APP 查询生产记录', tags: ['produce-app'])]
|
||||
#[DataResponse(description: '查询成功')]
|
||||
#[AllowAnonymous(requireToken: false, requirePermission: false, description: 'APP 公共接口')]
|
||||
public function queryRecords(Request $request): Response
|
||||
{
|
||||
return $this->delegate->queryRecords($request);
|
||||
@@ -94,7 +93,6 @@ final class DataEntryController
|
||||
|
||||
#[OA\Get(path: '/produce/query/timeline', summary: 'APP 查询订单生产时间线', tags: ['produce-app'])]
|
||||
#[DataResponse(description: '查询成功')]
|
||||
#[AllowAnonymous(requireToken: false, requirePermission: false, description: 'APP 公共接口')]
|
||||
public function queryTimeline(Request $request): Response
|
||||
{
|
||||
return $this->delegate->queryTimeline($request);
|
||||
@@ -224,4 +222,3 @@ final class DataEntryController
|
||||
return $this->delegate->productRecord($request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class RepairRecord extends BaseModel
|
||||
protected $with = ['exception'];
|
||||
|
||||
protected $fillable = [
|
||||
'id', 'repair_no', 'exception_id', 'exception_reason', 'exception_issue', 'order_no', 'product_code', 'product_name', 'category_id', 'category_ids', 'category_name', 'category_names',
|
||||
'id', 'repair_no', 'exception_id', 'exception_reason', 'exception_issue', 'order_no', 'product_code', 'product_name', 'process_id', 'step_id', 'process_name', 'step_name', 'category_id', 'category_ids', 'category_name', 'category_names',
|
||||
'fault_description_id', 'fault_description', 'repair_result', 'parts', 'images', 'operation_fault_steps', 'responsible_operators', 'operator', 'repaired_at',
|
||||
'remark', 'created_by', 'updated_by', 'created_at', 'updated_at', 'deleted_at',
|
||||
];
|
||||
@@ -33,6 +33,8 @@ class RepairRecord extends BaseModel
|
||||
'images' => 'array',
|
||||
'operation_fault_steps' => 'array',
|
||||
'responsible_operators' => 'array',
|
||||
'process_id' => 'integer',
|
||||
'step_id' => 'integer',
|
||||
'repaired_at' => 'integer',
|
||||
'created_at' => 'integer',
|
||||
'updated_at' => 'integer',
|
||||
|
||||
@@ -91,14 +91,20 @@ class WorkStatisticsService
|
||||
$connection = $model->getConnection();
|
||||
$table = $model->getTable();
|
||||
$exceptionTable = $exceptionModel->getTable();
|
||||
$flow = $this->getRepairFlow();
|
||||
$query = $connection->table($table)
|
||||
->leftJoin($exceptionTable, $exceptionTable . '.id', '=', $table . '.exception_id')
|
||||
->whereBetween($table . '.repaired_at', [$start, $end])
|
||||
->whereNull($table . '.deleted_at');
|
||||
|
||||
if (!$this->matchesRepairFlow($params, $flow)) {
|
||||
$query->whereRaw('1 = 0');
|
||||
foreach (['process_id', 'step_id'] as $field) {
|
||||
if (($params[$field] ?? '') !== '') {
|
||||
$query->where($table . '.' . $field, $params[$field]);
|
||||
}
|
||||
}
|
||||
foreach (['process_name', 'step_name'] as $field) {
|
||||
if (($params[$field] ?? '') !== '') {
|
||||
$query->where($table . '.' . $field, 'like', '%' . $params[$field] . '%');
|
||||
}
|
||||
}
|
||||
if (($params['user_code'] ?? '') !== '') {
|
||||
$query->where($exceptionTable . '.user_code', 'like', '%' . $params['user_code'] . '%');
|
||||
@@ -109,8 +115,8 @@ class WorkStatisticsService
|
||||
|
||||
return $query->select([$exceptionTable . '.user_code', $table . '.operator'])
|
||||
->selectRaw($periodExpression($connection->getTablePrefix() . $table . '.repaired_at') . ' as period')
|
||||
->selectRaw('? as process_name', [$flow['process_name']])
|
||||
->selectRaw('? as step_name', [$flow['step_name']])
|
||||
->selectRaw("COALESCE(NULLIF({$connection->getTablePrefix()}{$table}.process_name, ''), '历史数据未记录') as process_name")
|
||||
->selectRaw("COALESCE(NULLIF({$connection->getTablePrefix()}{$table}.step_name, ''), '历史数据未记录') as step_name")
|
||||
->selectRaw('COUNT(*) as amount')
|
||||
->groupBy('user_code', 'operator', 'process_name', 'step_name', 'period');
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<?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_repair_record';
|
||||
if (!$schema->hasTable($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasProcessId = $schema->hasColumn($table, 'process_id');
|
||||
$hasStepId = $schema->hasColumn($table, 'step_id');
|
||||
$hasProcessName = $schema->hasColumn($table, 'process_name');
|
||||
$hasStepName = $schema->hasColumn($table, 'step_name');
|
||||
|
||||
$schema->table($table, function (Blueprint $blueprint) use ($hasProcessId, $hasStepId, $hasProcessName, $hasStepName): void {
|
||||
if (!$hasProcessId) {
|
||||
$blueprint->bigInteger('process_id')->nullable()->comment('维修工序ID快照');
|
||||
$blueprint->index('process_id', 'idx_repair_record_process_id');
|
||||
}
|
||||
if (!$hasStepId) {
|
||||
$blueprint->bigInteger('step_id')->nullable()->comment('维修步骤ID快照');
|
||||
$blueprint->index('step_id', 'idx_repair_record_step_id');
|
||||
}
|
||||
if (!$hasProcessName) {
|
||||
$blueprint->string('process_name', 100)->nullable()->comment('维修工序名称快照');
|
||||
}
|
||||
if (!$hasStepName) {
|
||||
$blueprint->string('step_name', 100)->nullable()->comment('维修步骤名称快照');
|
||||
}
|
||||
});
|
||||
|
||||
$this->backfillSnapshots($schema);
|
||||
}
|
||||
|
||||
private function backfillSnapshots(Builder $schema): void
|
||||
{
|
||||
$connection = $schema->getConnection();
|
||||
if (!$schema->hasTable('produce_exception_record')) {
|
||||
$connection->table('produce_repair_record')->update([
|
||||
'process_name' => '历史数据未记录',
|
||||
'step_name' => '历史数据未记录',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
$records = $connection->table('produce_repair_record')->get(['id', 'exception_id']);
|
||||
foreach ($records as $record) {
|
||||
$exception = $connection->table('produce_exception_record')
|
||||
->where('id', $record->exception_id)
|
||||
->first(['process_id', 'step_id']);
|
||||
$processId = $exception?->process_id;
|
||||
$stepId = $exception?->step_id;
|
||||
$processName = $processId
|
||||
? (string) ($connection->table('produce_process')->where('id', $processId)->value('process_name') ?? '')
|
||||
: '';
|
||||
$stepName = $stepId
|
||||
? (string) ($connection->table('produce_process_step')->where('id', $stepId)->value('step_name') ?? '')
|
||||
: '';
|
||||
|
||||
$connection->table('produce_repair_record')->where('id', $record->id)->update([
|
||||
'process_id' => $processId,
|
||||
'step_id' => $stepId,
|
||||
'process_name' => $processName ?: '历史数据未记录',
|
||||
'step_name' => $stepName ?: '历史数据未记录',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Builder $schema): void
|
||||
{
|
||||
$table = 'produce_repair_record';
|
||||
if (!$schema->hasTable($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table($table, function (Blueprint $blueprint) use ($schema): void {
|
||||
if ($schema->hasColumn('produce_repair_record', 'process_id')) {
|
||||
$blueprint->dropIndex('idx_repair_record_process_id');
|
||||
$blueprint->dropColumn('process_id');
|
||||
}
|
||||
if ($schema->hasColumn('produce_repair_record', 'step_id')) {
|
||||
$blueprint->dropIndex('idx_repair_record_step_id');
|
||||
$blueprint->dropColumn('step_id');
|
||||
}
|
||||
if ($schema->hasColumn('produce_repair_record', 'process_name')) {
|
||||
$blueprint->dropColumn('process_name');
|
||||
}
|
||||
if ($schema->hasColumn('produce_repair_record', 'step_name')) {
|
||||
$blueprint->dropColumn('step_name');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+20
@@ -152,6 +152,8 @@ export const crudSchema = (): CrudSchema => ({
|
||||
{ field: "order_product", title: "订单号/商品编码" },
|
||||
{ field: "exception_type_name", title: "异常分类" },
|
||||
{ field: "exception_reason", title: "异常原因" },
|
||||
{ field: "process_name", title: "维修工序" },
|
||||
{ field: "step_name", title: "维修步骤" },
|
||||
{ field: "part_category", title: "物料分类" },
|
||||
{ field: "part_code", title: "物料编码" },
|
||||
{ field: "part_name", title: "更换备件" },
|
||||
@@ -221,6 +223,8 @@ export const crudSchema = (): CrudSchema => ({
|
||||
},
|
||||
{ prop: "exception_type_name", label: "异常分类", minWidth: 120, cellStyle: { whiteSpace: "nowrap" } },
|
||||
{ prop: "exception_reason", label: "异常原因", minWidth: 180, cellStyle: { whiteSpace: "nowrap" } },
|
||||
{ prop: "process_name", label: "维修工序", minWidth: 140, cellStyle: { whiteSpace: "nowrap" } },
|
||||
{ prop: "step_name", label: "维修步骤", minWidth: 140, cellStyle: { whiteSpace: "nowrap" } },
|
||||
{
|
||||
prop: "part_category",
|
||||
label: "物料分类",
|
||||
@@ -380,6 +384,22 @@ export const crudSchema = (): CrudSchema => ({
|
||||
ifDetail: true,
|
||||
colSpan: 24,
|
||||
},
|
||||
{
|
||||
component: "Input",
|
||||
prop: "process_name",
|
||||
label: "维修工序",
|
||||
componentProps: { readonly: true },
|
||||
ifDetail: true,
|
||||
colSpan: 12,
|
||||
},
|
||||
{
|
||||
component: "Input",
|
||||
prop: "step_name",
|
||||
label: "维修步骤",
|
||||
componentProps: { readonly: true },
|
||||
ifDetail: true,
|
||||
colSpan: 12,
|
||||
},
|
||||
{
|
||||
component: "Input",
|
||||
prop: "fault_descriptions",
|
||||
|
||||
@@ -229,6 +229,7 @@ return [
|
||||
'content' => [
|
||||
"site_name" => "",
|
||||
"site_url" => "",
|
||||
"require_login" => 1,
|
||||
"seo_title" => "",
|
||||
"seo_keywords" => "",
|
||||
"seo_description" => "",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Web 首页原型
|
||||
|
||||
## 调整内容
|
||||
|
||||
- 将首页从通用文档展示改为生产运营中心原型。
|
||||
- 新增生产达成率欢迎区、核心指标卡片、快捷工作入口、订单流程进度、现场动态和生产统计入口。
|
||||
- 快捷入口连接现有生产录入、异常提报、维修记录、订单查询及月度统计页面。
|
||||
- 使用项目已有 Nuxt、Element Plus、Iconify 和广告组件,补充 1440px、1024px、768px 下的响应式布局。
|
||||
|
||||
## 验证
|
||||
|
||||
- Web 端 `vue-tsc --noEmit` 已执行;项目存在多处既有类型错误,本次首页额外的广告组件目录导入已改为显式 `index.vue` 路径。
|
||||
@@ -0,0 +1,11 @@
|
||||
# Web 端登录门禁
|
||||
|
||||
## 调整内容
|
||||
|
||||
- 新增全局路由中间件,未登录访问任意 Web 页面时自动打开现有登录弹窗。
|
||||
- 默认布局在未登录状态下不渲染页面插槽,仅显示“请先登录”提示卡和登录按钮,避免预览业务页面内容。
|
||||
- 登录成功后依赖 Pinia 用户状态响应式更新,页面主体自动恢复,无需刷新。
|
||||
|
||||
## 验证
|
||||
|
||||
- Web 端 `pnpm build` 构建验证通过。
|
||||
@@ -0,0 +1,12 @@
|
||||
# Web 端登录访问开关
|
||||
|
||||
## 调整内容
|
||||
|
||||
- 在后台 `/setting/config` 的“前台设置”中新增“访问页面需要登录”配置。
|
||||
- 选择“是”时,Web 端未登录用户只能看到登录提示并打开登录弹窗;选择“否”时允许游客正常访问页面。
|
||||
- Web 端配置 Store 读取 `web_site_setting.require_login`,默认保持需要登录。
|
||||
- 全局认证中间件等待前台配置加载后再执行门禁判断,避免配置关闭时误拦截游客。
|
||||
|
||||
## 验证
|
||||
|
||||
- 在 `frontend/web` 目录执行 `pnpm build` 构建验证。
|
||||
@@ -0,0 +1,10 @@
|
||||
# Web 端主题色调整为蓝色
|
||||
|
||||
## 调整内容
|
||||
|
||||
- 将 Element Plus 全局主色从紫色调整为蓝色 `#2563eb`。
|
||||
- 页面中已有的蓝色业务配色继续保留,统一使用蓝色主题视觉。
|
||||
|
||||
## 验证
|
||||
|
||||
- 在 `frontend/web` 目录执行 `pnpm build` 构建验证。
|
||||
@@ -0,0 +1,306 @@
|
||||
# 生产插件功能完善建议
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文档基于当前生产插件的 Web 端、Admin 端、扫码端和后端实现整理,目标是明确后续功能补强方向,避免继续出现“页面已完成但流程数据无法追溯”或“不同端实现不一致”的问题。
|
||||
|
||||
涉及目录:
|
||||
|
||||
- 后端:`backend/plugin/produce`
|
||||
- Web 端:`frontend/web/app/apps/produce`
|
||||
- Admin 端:`frontend/admin/src/apps/produce`
|
||||
- 扫码端:`frontend/scanapp`
|
||||
|
||||
## 2. 当前能力概览
|
||||
|
||||
### 2.1 已具备的功能
|
||||
|
||||
- 生产录入:订单模式、整机模式、一单多机、工序/步骤/状态联动。
|
||||
- 异常处理:异常类型、原因、具体问题、图片、异常提报和异常查询。
|
||||
- 维修处理:待维修订单、维修结果、责任人员、故障描述、备件更换和维修提交。
|
||||
- 工艺配置:工序、工序步骤、步骤状态、步骤关系、状态转换、转换日志。
|
||||
- 物料管理:物料分类、物料库、物料搜索、故障描述和备件使用频率排序。
|
||||
- 查询统计:订单流程时间线、扫码数据、异常数据、维修数据、月度统计、日统计。
|
||||
- 多端入口:Web 端、Admin 管理端和 `scanapp` 扫码端均已有生产相关页面。
|
||||
|
||||
### 2.2 当前实现特点
|
||||
|
||||
- 后端 Admin API 采用标准 CRUD,前台和扫码业务集中在 `DataEntryController`。
|
||||
- 工序状态机已经支持前置步骤、终态校验、重复扫码策略和自动状态转换。
|
||||
- 异常记录已保存工序和步骤;维修记录目前主要通过异常记录关联维修流程。
|
||||
- 维修备件和责任人员采用 JSON 快照保存,能够保留提交时的名称信息。
|
||||
- 统计服务已经支持异常、维修、扫码、整机数据的合并统计。
|
||||
|
||||
## 3. 建议优先修复的基础问题(P0)
|
||||
|
||||
### 3.1 维修记录保存工序/步骤快照
|
||||
|
||||
**现状问题**
|
||||
|
||||
- `produce_repair_record` 当前没有独立的 `process_id`、`step_id`、工序名称和步骤名称字段。
|
||||
- 统计服务中的维修记录工序步骤来自当前“维修流程”配置,而不是维修提交当时的实际流程。
|
||||
- 后台修改维修流程后,历史维修数据可能被统计到新的工序步骤,历史数据无法准确复盘。
|
||||
|
||||
**建议方案**
|
||||
|
||||
- 维修提交时保存 `process_id`、`step_id`、`process_name`、`step_name` 四个快照字段。
|
||||
- 维修记录列表、详情、导出、订单时间线和月/日统计优先使用记录自身快照。
|
||||
- 对历史数据执行一次回填:优先读取关联异常记录的工序步骤,无法匹配时标记为“历史数据未记录”。
|
||||
- 后续修改维修流程只影响新提交数据,不改变历史统计口径。
|
||||
|
||||
**验收标准**
|
||||
|
||||
- 修改后台维修流程后,旧维修记录的工序步骤不发生变化。
|
||||
- Web、Admin、扫码端和月/日统计显示同一组工序步骤。
|
||||
- 维修记录导出包含工序名称、步骤名称和对应编码。
|
||||
|
||||
### 3.2 防止扫码、异常和维修重复提交
|
||||
|
||||
**现状问题**
|
||||
|
||||
- 当前主要依赖业务规则判断重复扫码,网络重试或用户连续点击仍可能产生重复请求。
|
||||
- 扫码端更容易遇到弱网、超时和重复点击场景。
|
||||
|
||||
**建议方案**
|
||||
|
||||
- 每次提交由客户端生成 `client_request_id`,服务端建立唯一约束或幂等记录。
|
||||
- 相同请求重复提交时返回第一次提交结果,而不是再次写入数据。
|
||||
- 提交按钮增加处理中状态,扫码成功后短时间内锁定同一条码。
|
||||
- 对订单号、工序、步骤、用户编码、数量等关键字段增加服务端事务锁和最终校验。
|
||||
|
||||
**验收标准**
|
||||
|
||||
- 同一请求重试不增加记录数量。
|
||||
- 网络超时后再次点击能够得到明确的“已提交”结果。
|
||||
- 并发提交不会绕过重复扫码和数量限制。
|
||||
|
||||
### 3.3 区分提报人、处理人和实际作业人员
|
||||
|
||||
**现状问题**
|
||||
|
||||
- 当前多个接口同时使用 `user_code`、`operator`、`reporter` 等字段,不同页面对“登录账号”和“实际作业人员”的理解容易混淆。
|
||||
- 维修责任人员已经支持手工录入用户编码,但审计信息还不够统一。
|
||||
|
||||
**建议方案**
|
||||
|
||||
- 统一保存以下信息:实际作业人员编码/姓名、系统提交人 ID/姓名、最后处理人 ID/姓名、提交时间和客户端来源。
|
||||
- 用户编码校验统一复用成员查询服务,不允许各端自行按不同字段判断。
|
||||
- 所有列表和时间线同时展示“工号 + 姓名”,详情中展示系统操作人。
|
||||
- 对手工代录场景增加“代录原因”和“授权人”字段,并写入操作日志。
|
||||
|
||||
### 3.4 建立异常到维修的完整闭环
|
||||
|
||||
**现状问题**
|
||||
|
||||
- 当前异常、待维修、维修提交和查询已经存在,但缺少统一的状态流转约束和超时提醒。
|
||||
- 异常处理状态、维修结果和订单当前状态可能出现不一致。
|
||||
|
||||
**建议方案**
|
||||
|
||||
- 明确状态机:待处理 → 已分派 → 维修中 → 待验证 → 已完成 / 已关闭 / 已退回。
|
||||
- 异常只能通过维修提交、人工关闭或退回操作改变状态,禁止直接修改造成跳状态。
|
||||
- 一条异常允许多次维修尝试,但必须保留每次维修记录和失败原因。
|
||||
- 增加维修超时、待验证超时和重复返修提醒。
|
||||
|
||||
## 4. 工艺和流程能力增强(P1)
|
||||
|
||||
### 4.1 工序流程可视化和发布机制
|
||||
|
||||
当前已有工序、步骤、步骤关系和状态转换四类配置,但配置结果缺少可视化验证。
|
||||
|
||||
建议增加:
|
||||
|
||||
- 工序流程图:显示正常流转、异常流转、前置步骤和终态。
|
||||
- 流程校验:检查孤立步骤、循环关系、无初始状态、多个初始状态、无可达终态等问题。
|
||||
- 草稿/发布机制:编辑中的流程不立即影响现场,发布后生成流程版本。
|
||||
- 生效时间和版本回滚:历史记录绑定流程版本,支持按版本追溯。
|
||||
- 流程模拟器:输入订单、步骤、状态和事件,预览下一步可执行转换。
|
||||
|
||||
### 4.2 前置步骤和重复扫码规则可视化
|
||||
|
||||
`ProcessStep` 已具备前置步骤、前置验证模式、终态要求和重复扫码状态等字段,建议在 Admin 页面完整开放:
|
||||
|
||||
- 用步骤选择器代替手工填写 ID。
|
||||
- 用“全部完成 / 任意完成 / 无需验证”下拉框代替编码输入。
|
||||
- 用状态多选展示允许重复扫码的状态。
|
||||
- 保存前给出规则冲突提示,避免把当前步骤设置为自己的前置步骤。
|
||||
|
||||
### 4.3 状态转换配置器
|
||||
|
||||
状态转换中的 `event_config` 和 `side_effect_config` 当前适合技术人员维护,不适合普通管理员。
|
||||
|
||||
建议分层实现:
|
||||
|
||||
- 首期提供 JSON 格式校验、格式化和示例模板。
|
||||
- 后续提供条件构造器、通知配置器、字段更新配置器和事件参数表单。
|
||||
- 配置保存前执行字段白名单校验,禁止任意表达式或脚本执行。
|
||||
|
||||
## 5. 质量、维修和物料能力增强(P1)
|
||||
|
||||
### 5.1 维修备件管理从快照升级为库存业务
|
||||
|
||||
当前维修记录的 `parts` 以 JSON 快照保存,适合记录历史,但不能支持库存管理。
|
||||
|
||||
建议增加:
|
||||
|
||||
- 备件单位、库存数量、安全库存、库位、供应商和启用状态。
|
||||
- 维修领料、退料、旧件回收和库存流水。
|
||||
- 备件替代关系:一个物料允许配置可替代物料。
|
||||
- 维修提交时校验库存,支持“只记录不扣库存”和“提交即扣库存”两种策略。
|
||||
- 备件消耗排行、故障关联排行和库存预警。
|
||||
|
||||
### 5.2 故障描述和责任分析
|
||||
|
||||
当前已有故障描述使用次数和责任人员快照,建议补充:
|
||||
|
||||
- 故障描述的启用范围:按产品、物料分类、工序或步骤限制。
|
||||
- 故障原因、责任类型、责任部门和责任人员的分层统计。
|
||||
- 缺陷 Pareto 图、重复故障率、一次修复率和返修率。
|
||||
- 支持从统计图直接下钻到异常记录和维修记录。
|
||||
|
||||
### 5.3 附件和图片统一管理
|
||||
|
||||
异常和维修均支持图片,但建议从 JSON URL 记录进一步完善为附件管理:
|
||||
|
||||
- 限制文件类型、单文件大小、数量和图片分辨率。
|
||||
- 记录上传人、上传时间、业务类型和关联记录。
|
||||
- 删除业务记录时保留附件引用检查,避免误删仍被其他记录使用的文件。
|
||||
- 列表只显示缩略图,详情支持原图查看和下载。
|
||||
|
||||
## 6. 统计和查询能力增强(P1)
|
||||
|
||||
### 6.1 统一统计口径
|
||||
|
||||
当前月/日统计已合并扫码、整机、异常和维修数据,建议明确每类指标的定义:
|
||||
|
||||
- 生产量:按订单数量、扫码次数还是合格数量统计。
|
||||
- 异常量:按异常单数还是异常问题数统计。
|
||||
- 维修量:按维修单数、维修尝试次数还是完成数量统计。
|
||||
- 维修工序步骤:必须使用提交时快照,不能读取当前配置。
|
||||
|
||||
### 6.2 增加质量和效率指标
|
||||
|
||||
建议新增以下指标:
|
||||
|
||||
- 首次通过率(FPY)。
|
||||
- 异常率、每千台缺陷数和缺陷密度。
|
||||
- 平均维修时长(MTTR)。
|
||||
- 异常响应时长和待维修积压时长。
|
||||
- 各工序/步骤产能、异常率、维修率和返修率。
|
||||
- 按产品、物料、班组、工号、责任人员和时间段进行对比。
|
||||
|
||||
### 6.3 查询页面增强
|
||||
|
||||
- 统一异常、维修、扫码、整机四类记录的筛选条件和分页行为。
|
||||
- 时间线支持按事件类型、工序、步骤、工号、操作人和状态筛选。
|
||||
- 查询结果支持导出当前筛选条件,并在导出文件中保留快照字段。
|
||||
- 对大时间范围查询增加异步导出,避免请求超时。
|
||||
- 为常用查询保存筛选方案,例如“今日待维修”“本周高频故障”。
|
||||
|
||||
## 7. 扫码端和现场作业增强(P1)
|
||||
|
||||
### 7.1 弱网和离线队列
|
||||
|
||||
`scanapp` 目前以接口实时提交为主,现场网络中断时容易影响作业连续性。
|
||||
|
||||
建议增加:
|
||||
|
||||
- 本地待提交队列,保存条码、工序、步骤、状态、工号和请求编号。
|
||||
- 网络恢复后自动重试,服务端按请求编号幂等处理。
|
||||
- 队列状态页:待同步、同步中、成功、失败和需人工处理。
|
||||
- 离线期间禁止使用无法本地校验的高风险操作,并明确提示用户。
|
||||
|
||||
### 7.2 扫码效率和设备能力
|
||||
|
||||
- 连续扫码模式和批量数量录入。
|
||||
- 扫码音效、震动、闪光灯和成功/失败颜色反馈可配置。
|
||||
- 设备绑定、设备编号、车间/产线绑定和异常设备上报。
|
||||
- 支持扫码枪键盘输入与摄像头扫码自动切换。
|
||||
- 对重复条码、无效条码和跨工序条码提供明确的现场提示。
|
||||
|
||||
### 7.3 现场首页
|
||||
|
||||
扫码端首页建议显示:
|
||||
|
||||
- 当前登录工号和当前班次。
|
||||
- 当前工序/步骤和待完成数量。
|
||||
- 待处理异常、待维修订单和离线待同步数量。
|
||||
- 常用操作入口:生产录入、异常提报、维修处理、查询。
|
||||
|
||||
## 8. 权限、审计和安全(P0/P1)
|
||||
|
||||
- 按角色限制生产录入、异常提报、维修处理、流程配置、统计查看和数据导出。
|
||||
- 工艺配置修改必须记录前后值、修改人、修改时间和发布版本。
|
||||
- 敏感字段如用户编码、手机号和图片下载地址按权限脱敏。
|
||||
- 所有导出记录写入审计日志,包含筛选条件和导出人。
|
||||
- 前台公共接口只开放必要的查询和提交能力,管理接口继续使用权限中间件。
|
||||
- 关键业务接口增加限流、请求编号、来源端标识和异常告警。
|
||||
|
||||
## 9. 多端一致性和工程维护建议
|
||||
|
||||
### 9.1 抽取共享业务规则
|
||||
|
||||
当前前台 API 和 AppAPI 都由 `DataEntryController` 承担大量业务方法,长期维护容易出现逻辑分叉。
|
||||
|
||||
建议:
|
||||
|
||||
- 将工序校验、步骤校验、重复扫码、异常关联维修、物料校验抽到领域 Service。
|
||||
- Web、扫码端和后台只负责参数接收、权限处理和结果格式化。
|
||||
- 为共享 Service 增加接口级测试和关键场景测试。
|
||||
|
||||
### 9.2 模板源优先
|
||||
|
||||
生产插件前端正式维护顺序应保持:
|
||||
|
||||
1. 先修改 `backend/plugin/produce/resource/template`。
|
||||
2. 再同步到 `frontend/admin/src/apps/produce` 或 `frontend/web/app/apps/produce`。
|
||||
3. 核对模板源和运行目录内容一致。
|
||||
4. 对涉及安装复制的改动执行一次重装回归验证。
|
||||
|
||||
### 9.3 接口契约和错误码
|
||||
|
||||
- 统一返回字段:记录 ID、请求编号、业务状态、可重试标记和用户提示。
|
||||
- 错误信息区分“用户输入错误”“流程配置错误”“权限错误”“系统暂时不可用”。
|
||||
- 为工序、步骤、状态、异常、维修和统计接口补充稳定的请求/响应类型定义。
|
||||
|
||||
## 10. 推荐实施路线
|
||||
|
||||
### 第一阶段:数据正确性和现场稳定性
|
||||
|
||||
1. 维修记录增加工序/步骤快照并回填历史数据。
|
||||
2. 增加提交幂等机制和重复点击保护。
|
||||
3. 统一实际作业人员、系统操作人和代录信息。
|
||||
4. 梳理异常到维修的状态闭环。
|
||||
5. 修正统计和时间线的数据口径。
|
||||
|
||||
### 第二阶段:管理效率和统计决策
|
||||
|
||||
1. 工序流程可视化、规则校验和流程发布版本。
|
||||
2. 统计增加 FPY、异常率、MTTR、返修率和 Pareto 分析。
|
||||
3. 维修备件库存、领退料和库存预警。
|
||||
4. 附件统一管理和导出能力增强。
|
||||
5. 查询页面支持保存筛选方案和异步导出。
|
||||
|
||||
### 第三阶段:现场数字化和智能化
|
||||
|
||||
1. 扫码端离线队列和自动同步。
|
||||
2. 设备、产线、班次和工位管理。
|
||||
3. 现场首页和班组看板。
|
||||
4. 流程模拟器、异常趋势预警和责任分析。
|
||||
5. 在数据稳定后再评估预测性维修或智能推荐功能。
|
||||
|
||||
## 11. 建议优先立项的五项功能
|
||||
|
||||
如果只选择最有价值的五项,建议顺序如下:
|
||||
|
||||
1. **维修工序/步骤快照**:直接解决历史统计失真的核心问题。
|
||||
2. **提交幂等和离线同步基础**:减少现场重复数据和漏数据。
|
||||
3. **异常—维修状态闭环**:让待维修、维修中、验证和关闭有统一规则。
|
||||
4. **流程版本和可视化校验**:降低工艺配置错误对生产现场的影响。
|
||||
5. **质量效率指标看板**:把现有记录转化为可执行的管理决策。
|
||||
|
||||
## 12. 结论
|
||||
|
||||
生产插件的基础模块已经比较完整,下一步重点不应只是继续增加页面,而应优先保证“数据在提交时被正确记录、历史可追溯、现场可持续作业、统计口径一致”。
|
||||
|
||||
建议以第一阶段五项功能为主线,完成数据模型和接口契约后,再扩展库存、流程版本、离线和智能分析,能够以较小风险获得最大的业务收益。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 维修记录工序步骤快照
|
||||
|
||||
## 调整内容
|
||||
|
||||
- 新增维修记录的 `process_id`、`step_id`、`process_name`、`step_name` 字段,保存维修提交时的工序步骤快照。
|
||||
- 新增迁移会优先从关联异常记录回填历史维修数据;无法匹配的记录标记为“历史数据未记录”。
|
||||
- 维修提交使用当时的维修流程配置写入快照,后续修改维修流程不会改变历史记录。
|
||||
- Web 查询、订单时间线、Admin 维修数据列表/详情/导出以及月度、日度统计改为优先使用维修记录快照。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `backend/plugin/produce/resource/database/migrations/2026_09_03_000001_add_process_step_snapshot_to_repair_record.php`
|
||||
- `backend/plugin/produce/app/model/RepairRecord.php`
|
||||
- `backend/plugin/produce/app/api/controller/DataEntryController.php`
|
||||
- `backend/plugin/produce/app/service/admin/WorkStatisticsService.php`
|
||||
- `backend/plugin/produce/resource/template/admin/views/produce/repair/repair-data/schemas/index.tsx`
|
||||
- `frontend/admin/src/apps/produce/views/produce/repair/repair-data/schemas/index.tsx`
|
||||
|
||||
## 验证
|
||||
|
||||
- 已执行相关 PHP 文件语法检查。
|
||||
- 已核对 Admin 维修数据模板源与运行目录 Schema 内容一致。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 查询按钮安卓间距
|
||||
|
||||
针对安卓真机上按钮间距过小的问题,改用显式 `margin-right` 设置记录类型按钮间隔,并取消依赖 `gap`,保持五个按钮等宽且不相互贴合。
|
||||
@@ -0,0 +1,5 @@
|
||||
# APP 查询详情弹窗状态修复
|
||||
|
||||
- 查询开始时清理上一条记录的详情状态,避免刷新期间旧弹窗残留。
|
||||
- 页面隐藏时关闭详情弹窗。
|
||||
- 查询结果变化后若当前记录已不存在,自动关闭旧详情并重新选择当前结果。
|
||||
@@ -0,0 +1,4 @@
|
||||
# APP 查询结果详情点击修复
|
||||
|
||||
- 查询结果卡片同时兼容 `tap` 和 `click` 事件,适配 Android 真机触摸事件。
|
||||
- 详情打开逻辑增加空记录保护,并在下一次渲染周期显示详情弹窗。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 安卓详情点击兼容修复
|
||||
|
||||
针对 Web 正常、安卓真机无法打开详情的问题,列表卡片恢复使用 uni-app 原生 `@tap` 事件,移除组合事件、事件修饰符和延迟状态更新,避免不同端编译行为差异。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 安卓详情弹窗宽度修复
|
||||
|
||||
Android WebView 对 `inset` 定位兼容性不一致,详情遮罩改为显式设置上下左右边界并补充宽度,底部详情面板强制铺满可用宽度,修复弹窗偏窄或错位问题。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 详情弹窗底部定位修复
|
||||
|
||||
修正 flex 布局中 `align-self: stretch` 导致详情面板高度被拉满、从顶部展开的问题。遮罩改为纵向 flex 并使用 `justify-content: flex-end`,确保弹窗始终从底部弹出且保持全宽。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 查询输入框高度优化
|
||||
|
||||
查询输入框、扫码按钮和查询按钮统一调整为 `80rpx` 高度,并同步输入文字行高,改善真机上输入区域偏小的问题。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 查询按钮宽度优化
|
||||
|
||||
扫码按钮宽度调整为 `80rpx`,查询按钮宽度调整为 `128rpx`,与输入框高度同步优化并保持操作区域比例协调。
|
||||
@@ -0,0 +1,4 @@
|
||||
# 查询页面登录权限控制
|
||||
|
||||
- APP 查询页进入时检查登录令牌,未登录仅提示“请先登录后再查询”,不自动跳转。
|
||||
- Web/API 与 APP/API 的生产记录、订单时间线查询接口取消匿名访问,必须携带有效登录令牌。
|
||||
@@ -0,0 +1,3 @@
|
||||
# 查询页未登录仅提示
|
||||
|
||||
未登录访问 APP 查询页时仅显示提示信息,不再自动跳转登录页面;页面生命周期不会重复触发登录跳转。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 登录 Token 有效期配置
|
||||
|
||||
## 变更内容
|
||||
|
||||
- 增加系统配置“登录Token”,可分别设置 Admin、Web、APP 三端 Access Token 有效期。
|
||||
- Refresh Token 有效期支持按 Admin、Web、APP 分别设置,默认均为 7 天。
|
||||
- JWT Redis 会话记录按 Refresh Token 有效期保存,Access Token 过期后可正常刷新。
|
||||
- APP 请求收到 401 时自动使用 Refresh Token 刷新并重试原请求。
|
||||
|
||||
## 默认值
|
||||
|
||||
- 三端 Access Token:3600 秒(1 小时)。
|
||||
- Refresh Token:604800 秒(7 天)。
|
||||
@@ -0,0 +1,16 @@
|
||||
# 附件管理浏览功能
|
||||
|
||||
## 变更内容
|
||||
|
||||
- 在 `/tools/files` 附件列表新增“浏览”操作。
|
||||
- 图片、视频、音频和 PDF 使用弹窗在线预览。
|
||||
- 其他文件类型提示下载,并支持新窗口打开。
|
||||
- 预览内容通过现有下载接口获取,沿用当前登录权限。
|
||||
- 附件列表新增预览列:图片显示缩略图,PDF、文档、表格、演示文稿、压缩包、音视频和未知格式显示对应文件图标。
|
||||
- 缩略图根据表格密度自适应:紧凑模式 38×38、默认模式 50×50、宽松模式 58×58,避免图片撑大行高。
|
||||
- 图片缩略图在列表单元格中居中显示,点击缩略图可打开图片预览弹窗。
|
||||
|
||||
## 验证
|
||||
|
||||
- 已检查页面代码结构。
|
||||
- Admin 全量构建仍受项目原有缺失文件 `src/components/form/components/image-selector/index.vue` 阻塞,与本次附件页面无关。
|
||||
@@ -0,0 +1,17 @@
|
||||
# APP 查询页面数据对接
|
||||
|
||||
## 变更内容
|
||||
|
||||
- 新增 `frontend/app/api/produce/query.js`,统一封装生产记录和订单时间线查询接口。
|
||||
- APP 查询页改用 `/appapi/produce/query/records` 真实数据,支持扫码、整机、异常、维修及全部类型查询。
|
||||
- 接入关键词查询、服务端分页参数、加载状态和失败提示。
|
||||
- 高级筛选调整为时间区间、工序和步骤选择,选项通过后端配置接口加载。
|
||||
- 查询接口补充 `process_id`、`step_id` 筛选参数,确保工序步骤筛选在服务端生效。
|
||||
- 页面初始保持待查询状态,不自动展示结果;点击查询或扫码后才显示数据。
|
||||
- 查询按钮增加输入校验,未填写关键词、订单号或用户工号时不会查询全部数据。
|
||||
- 工序选项根据返回记录动态补充,记录详情展示后端返回的工序、步骤、操作人、产品及业务信息。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node --check frontend/app/api/produce/query.js`
|
||||
- 提取查询页 `<script>` 后使用 `node --check` 校验通过。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 查询列表工序步骤显示
|
||||
|
||||
列表卡片的工序字段调整为“工序 / 步骤”组合展示,确保查询结果同时显示工序名称和步骤名称。
|
||||
@@ -0,0 +1,6 @@
|
||||
# APP 查询页面样式优化
|
||||
|
||||
- 优化页面背景、标题卡片和查询区域层级。
|
||||
- 调整记录类型标签为横向滚动布局,适配小屏幕。
|
||||
- 优化高级筛选、结果列表卡片的间距、阴影和可读性。
|
||||
- 补充深色模式下的背景、边框和文字对比度。
|
||||
@@ -0,0 +1,4 @@
|
||||
# APP 查询页标签与输入框样式修正
|
||||
|
||||
- 记录类型标签禁止文字换行,并增加单项最小宽度。
|
||||
- 查询输入框及其内部输入控件采用垂直居中布局,修正占位文字偏移问题。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 查询标签文案精简
|
||||
|
||||
将查询页标签由“扫码记录、整机记录、异常记录、维修记录”精简为“订单、整机、异常、维修”,保留原有查询类型和值不变。
|
||||
@@ -0,0 +1,3 @@
|
||||
# APP 查询按钮宽度调整
|
||||
|
||||
顶部记录类型按钮改为等比分配可用宽度,移除固定最小宽度并缩小内边距,避免按钮超出外层容器。
|
||||
@@ -152,6 +152,8 @@ export const crudSchema = (): CrudSchema => ({
|
||||
{ field: "order_product", title: "订单号/商品编码" },
|
||||
{ field: "exception_type_name", title: "异常分类" },
|
||||
{ field: "exception_reason", title: "异常原因" },
|
||||
{ field: "process_name", title: "维修工序" },
|
||||
{ field: "step_name", title: "维修步骤" },
|
||||
{ field: "part_category", title: "物料分类" },
|
||||
{ field: "part_code", title: "物料编码" },
|
||||
{ field: "part_name", title: "更换备件" },
|
||||
@@ -221,6 +223,8 @@ export const crudSchema = (): CrudSchema => ({
|
||||
},
|
||||
{ prop: "exception_type_name", label: "异常分类", minWidth: 120, cellStyle: { whiteSpace: "nowrap" } },
|
||||
{ prop: "exception_reason", label: "异常原因", minWidth: 180, cellStyle: { whiteSpace: "nowrap" } },
|
||||
{ prop: "process_name", label: "维修工序", minWidth: 140, cellStyle: { whiteSpace: "nowrap" } },
|
||||
{ prop: "step_name", label: "维修步骤", minWidth: 140, cellStyle: { whiteSpace: "nowrap" } },
|
||||
{
|
||||
prop: "part_category",
|
||||
label: "物料分类",
|
||||
@@ -380,6 +384,22 @@ export const crudSchema = (): CrudSchema => ({
|
||||
ifDetail: true,
|
||||
colSpan: 24,
|
||||
},
|
||||
{
|
||||
component: "Input",
|
||||
prop: "process_name",
|
||||
label: "维修工序",
|
||||
componentProps: { readonly: true },
|
||||
ifDetail: true,
|
||||
colSpan: 12,
|
||||
},
|
||||
{
|
||||
component: "Input",
|
||||
prop: "step_name",
|
||||
label: "维修步骤",
|
||||
componentProps: { readonly: true },
|
||||
ifDetail: true,
|
||||
colSpan: 12,
|
||||
},
|
||||
{
|
||||
component: "Input",
|
||||
prop: "fault_descriptions",
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
"title": "Frontend Settings",
|
||||
"site_name": "Site Name",
|
||||
"site_url": "Site URL",
|
||||
"require_login": "Require login to access pages",
|
||||
"require_login_options": { "enabled": "Yes", "disabled": "No" },
|
||||
"logo": "Site Logo",
|
||||
"seo_title": "SEO Title",
|
||||
"seo_keywords": "SEO Keywords",
|
||||
@@ -261,4 +263,4 @@
|
||||
"submit": "Save"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
"title": "前台设置",
|
||||
"site_name": "站点名称",
|
||||
"site_url": "站点URL",
|
||||
"require_login": "访问页面需要登录",
|
||||
"require_login_options": { "enabled": "是", "disabled": "否" },
|
||||
"logo": "站点Logo",
|
||||
"seo_title": "SEO标题",
|
||||
"seo_keywords": "SEO关键词",
|
||||
@@ -261,4 +263,4 @@
|
||||
"submit": "保存"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,14 @@
|
||||
key: '5',
|
||||
name: $t('setting.config.common.title'),
|
||||
loadKey: 0
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/setting/config/modules/auth-token.vue'))
|
||||
),
|
||||
key: '10',
|
||||
name: '登录Token',
|
||||
loadKey: 0
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="auth-token-config">
|
||||
<BasicForm />
|
||||
<el-button class="config-submit" type="primary" @click="onSubmit">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useForm } from '@/components/form'
|
||||
import ConfigService from '@/api/setting/config/config'
|
||||
|
||||
const schema = [
|
||||
{ prop: 'admin_access_ttl', label: 'Admin 端 Access Token 有效期(秒)', component: 'Input', componentProps: { type: 'number', min: 60, style: { width: '320px' } } },
|
||||
{ prop: 'web_access_ttl', label: 'Web 端 Access Token 有效期(秒)', component: 'Input', componentProps: { type: 'number', min: 60, style: { width: '320px' } } },
|
||||
{ prop: 'app_access_ttl', label: 'APP 端 Access Token 有效期(秒)', component: 'Input', componentProps: { type: 'number', min: 60, style: { width: '320px' } } },
|
||||
{ prop: 'admin_refresh_ttl', label: 'Admin 端 Refresh Token 有效期(秒)', component: 'Input', componentProps: { type: 'number', min: 300, style: { width: '320px' } } },
|
||||
{ prop: 'web_refresh_ttl', label: 'Web 端 Refresh Token 有效期(秒)', component: 'Input', componentProps: { type: 'number', min: 300, style: { width: '320px' } } },
|
||||
{ prop: 'app_refresh_ttl', label: 'APP 端 Refresh Token 有效期(秒)', component: 'Input', componentProps: { type: 'number', min: 300, style: { width: '320px' } } }
|
||||
]
|
||||
|
||||
const [BasicForm, formApi] = useForm({ labelPosition: 'right', labelWidth: '260px', schema: schema as any })
|
||||
|
||||
const retrieveData = async () => {
|
||||
const data = await ConfigService.getByCode('auth_token_setting') as any
|
||||
await formApi.setValues({
|
||||
admin_access_ttl: data?.admin_access_ttl ?? 3600,
|
||||
web_access_ttl: data?.web_access_ttl ?? 3600,
|
||||
app_access_ttl: data?.app_access_ttl ?? 3600,
|
||||
admin_refresh_ttl: data?.admin_refresh_ttl ?? data?.refresh_ttl ?? 604800,
|
||||
web_refresh_ttl: data?.web_refresh_ttl ?? data?.refresh_ttl ?? 604800,
|
||||
app_refresh_ttl: data?.app_refresh_ttl ?? data?.refresh_ttl ?? 604800
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = await formApi.getValues()
|
||||
const content = Object.fromEntries(Object.entries(values).map(([key, value]) => {
|
||||
const minimum = key.includes('refresh') ? 300 : 60
|
||||
return [key, Math.max(minimum, Number(value) || 0)]
|
||||
}))
|
||||
await ConfigService.update('auth_token_setting', { group_code: 'system', code: 'auth_token_setting', name: '登录Token有效期', content })
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
|
||||
onMounted(retrieveData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-submit { margin-left: 260px; }
|
||||
</style>
|
||||
@@ -37,6 +37,19 @@
|
||||
},
|
||||
rules: []
|
||||
},
|
||||
{
|
||||
prop: 'require_login',
|
||||
label: $t('setting.config.web_site.require_login'),
|
||||
component: 'RadioGroup',
|
||||
defaultValue: 1,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: $t('setting.config.web_site.require_login_options.enabled'), value: 1 },
|
||||
{ label: $t('setting.config.web_site.require_login_options.disabled'), value: 0 }
|
||||
]
|
||||
},
|
||||
rules: []
|
||||
},
|
||||
{
|
||||
prop: 'logo',
|
||||
label: $t('setting.config.web_site.logo'),
|
||||
@@ -169,7 +182,10 @@
|
||||
const retrieveData = async () => {
|
||||
const record = (await ConfigService.getByCode('web_site_setting')) as any
|
||||
|
||||
await formApi.setValues({ ...convertStringNumbers(record) })
|
||||
// 兼容旧配置数据:未保存该字段时默认要求登录
|
||||
const values = convertStringNumbers(record || {})
|
||||
values.require_login = values.require_login ?? 1
|
||||
await formApi.setValues(values)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -1,13 +1,81 @@
|
||||
<template>
|
||||
<div class="sys-post-page art-full-height">
|
||||
<BasicCrud />
|
||||
<el-dialog v-model="previewVisible" title="浏览附件" width="min(900px, 92vw)" destroy-on-close>
|
||||
<div class="file-preview-container">
|
||||
<img v-if="previewType === 'image'" :src="previewUrl" alt="附件预览" class="file-preview-media" />
|
||||
<video v-else-if="previewType === 'video'" :src="previewUrl" controls class="file-preview-media" />
|
||||
<audio v-else-if="previewType === 'audio'" :src="previewUrl" controls class="file-preview-audio" />
|
||||
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" title="PDF预览" class="file-preview-frame" />
|
||||
<div v-else class="file-preview-empty">该文件类型不支持在线预览,请点击下载。</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="openPreviewInNewTab">新窗口打开</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="tsx">
|
||||
import { ref } from 'vue'
|
||||
import { useCrud } from '@/components/crud'
|
||||
import { crudSchema } from './schemas'
|
||||
import { $t } from '@/locales'
|
||||
import { FilesService } from '@/api/tools/files'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const onFilePreview = (event: Event) => {
|
||||
const record = (event as CustomEvent).detail
|
||||
if (record) handlePreview(record)
|
||||
}
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref<'image' | 'video' | 'audio' | 'pdf' | 'other'>('other')
|
||||
let previewObjectUrl = ''
|
||||
|
||||
const getFileUrl = (record: any) => record.url || record.path || record.base_path || ''
|
||||
|
||||
const getPreviewType = (record: any) => {
|
||||
const ext = String(record.ext || record.extension || '').toLowerCase().replace('.', '')
|
||||
const mime = String(record.content_type || record.mime_type || '').toLowerCase()
|
||||
if (mime.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image'
|
||||
if (mime.startsWith('video/') || ['mp4', 'webm', 'ogg', 'mov'].includes(ext)) return 'video'
|
||||
if (mime.startsWith('audio/') || ['mp3', 'wav', 'm4a', 'aac', 'flac'].includes(ext)) return 'audio'
|
||||
if (mime === 'application/pdf' || ext === 'pdf') return 'pdf'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const handlePreview = async (record: any) => {
|
||||
const url = getFileUrl(record)
|
||||
if (!url) {
|
||||
ElMessage.warning('附件地址不存在')
|
||||
return
|
||||
}
|
||||
previewType.value = getPreviewType(record)
|
||||
if (previewObjectUrl) URL.revokeObjectURL(previewObjectUrl)
|
||||
if (previewType.value === 'other') {
|
||||
previewUrl.value = url
|
||||
previewVisible.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
const blob = await FilesService.downloadById(record.id)
|
||||
previewObjectUrl = URL.createObjectURL(blob instanceof Blob ? blob : new Blob([blob]))
|
||||
previewUrl.value = previewObjectUrl
|
||||
} catch (error) {
|
||||
ElMessage.error('附件加载失败,请稍后重试')
|
||||
return
|
||||
}
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const openPreviewInNewTab = () => {
|
||||
if (previewUrl.value) window.open(previewUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('file-preview', onFilePreview))
|
||||
onBeforeUnmount(() => window.removeEventListener('file-preview', onFilePreview))
|
||||
|
||||
const [BasicCrud, crudApi] = useCrud({
|
||||
...crudSchema(),
|
||||
@@ -32,6 +100,15 @@
|
||||
dropDownToolbarActions: [],
|
||||
// 表格行操作
|
||||
tableActions: [
|
||||
{
|
||||
label: '浏览',
|
||||
type: 'primary',
|
||||
sort: 1010,
|
||||
link: true,
|
||||
icon: 'ant-design:eye-outlined',
|
||||
auth: 'upload:files:read',
|
||||
onClick: (_e: Event, record: any) => handlePreview(record)
|
||||
},
|
||||
{
|
||||
label: $t('tools.files.list.table.columns.actions.download'),
|
||||
type: 'primary',
|
||||
@@ -88,4 +165,13 @@
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped>
|
||||
.file-preview-container { min-height: 240px; display: flex; align-items: center; justify-content: center; background: #f5f7fa; }
|
||||
.file-preview-media { max-width: 100%; max-height: 65vh; object-fit: contain; }
|
||||
.file-preview-audio { width: min(560px, 100%); }
|
||||
.file-preview-frame { width: 100%; height: 65vh; border: 0; }
|
||||
.file-preview-empty { color: #909399; padding: 60px 20px; }
|
||||
:global(.file-list-thumbnail) { display: block; width: 50px; height: 50px; max-width: 50px; max-height: 50px; margin: 0 auto; object-fit: cover; border-radius: 6px; border: 1px solid #ebeef5; cursor: pointer; }
|
||||
:global(.el-table--small .file-list-thumbnail) { width: 38px; height: 38px; max-width: 38px; max-height: 38px; }
|
||||
:global(.el-table--large .file-list-thumbnail) { width: 58px; height: 58px; max-width: 58px; max-height: 58px; }
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,39 @@ import { CrudSchema } from '@/components/crud'
|
||||
import { $t } from '@/locales'
|
||||
import { } from '@/api/setting/gateway'
|
||||
import { FilesService } from '@/api/tools/files'
|
||||
import { h } from 'vue'
|
||||
import { Icon } from '@/components/icon'
|
||||
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico']
|
||||
|
||||
const getFileExtension = (row: any) => String(row?.ext || row?.extension || '').toLowerCase().replace('.', '')
|
||||
|
||||
const getFileIcon = (ext: string) => {
|
||||
if (['pdf'].includes(ext)) return { name: 'ant-design:file-pdf-outlined', color: '#f56c6c' }
|
||||
if (['doc', 'docx', 'odt', 'txt', 'md'].includes(ext)) return { name: 'ant-design:file-word-outlined', color: '#409eff' }
|
||||
if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return { name: 'ant-design:file-excel-outlined', color: '#67c23a' }
|
||||
if (['ppt', 'pptx', 'odp'].includes(ext)) return { name: 'ant-design:file-ppt-outlined', color: '#e6a23c' }
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return { name: 'ant-design:file-zip-outlined', color: '#909399' }
|
||||
if (['mp4', 'webm', 'mov', 'avi'].includes(ext)) return { name: 'ant-design:video-camera-outlined', color: '#8e44ad' }
|
||||
if (['mp3', 'wav', 'm4a', 'aac'].includes(ext)) return { name: 'ant-design:audio-outlined', color: '#13c2c2' }
|
||||
return { name: 'ant-design:file-outlined', color: '#909399' }
|
||||
}
|
||||
|
||||
const renderFilePreview = (row: any) => {
|
||||
const ext = getFileExtension(row)
|
||||
const url = row?.url || row?.path || row?.base_path || ''
|
||||
if (imageExtensions.includes(ext) && url) {
|
||||
return h('img', {
|
||||
src: url,
|
||||
alt: row?.original_filename || row?.filename || '图片',
|
||||
class: 'file-list-thumbnail',
|
||||
title: '点击预览图片',
|
||||
onClick: () => window.dispatchEvent(new CustomEvent('file-preview', { detail: row }))
|
||||
})
|
||||
}
|
||||
const icon = getFileIcon(ext)
|
||||
return h(Icon, { icon: icon.name, width: 32, height: 32, color: icon.color })
|
||||
}
|
||||
|
||||
// crud配置
|
||||
export const crudSchema = (): CrudSchema => {
|
||||
@@ -42,6 +75,13 @@ export const crudSchema = (): CrudSchema => {
|
||||
{
|
||||
type: 'selection'
|
||||
},
|
||||
{
|
||||
prop: 'file_preview',
|
||||
label: '预览',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
formatter: (row: any) => renderFilePreview(row)
|
||||
},
|
||||
{
|
||||
prop: 'filename',
|
||||
label: $t('tools.files.list.table.columns.filename'),
|
||||
@@ -127,4 +167,4 @@ export const crudSchema = (): CrudSchema => {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/request.js'
|
||||
|
||||
/** 查询生产记录(扫码、整机、异常、维修)。 */
|
||||
export function queryProduceRecords(params = {}) {
|
||||
return request.get('/produce/query/records', params, { showError: false })
|
||||
}
|
||||
|
||||
/** 查询指定订单的生产时间线。 */
|
||||
export function queryProduceTimeline(orderNo) {
|
||||
return request.get('/produce/query/timeline', {
|
||||
order_no: String(orderNo || '').trim()
|
||||
}, { showError: false })
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const config = {
|
||||
tokenKey: 'scanapp_token',
|
||||
tokenHeaderKey: 'Authorization',
|
||||
channelHeaderKey: 'channel',
|
||||
channel: 'scanapp'
|
||||
channel: 'app'
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -3,13 +3,13 @@
|
||||
<view class="query-header">
|
||||
<view>
|
||||
<text class="query-title">记录查询</text>
|
||||
<text class="query-subtitle">快速定位扫码、批量与异常记录</text>
|
||||
<text class="query-subtitle">快速定位扫码、整机、异常与维修记录</text>
|
||||
</view>
|
||||
<u-icon name="search" color="#2f6cf6" size="34" />
|
||||
</view>
|
||||
|
||||
<view class="query-tabs">
|
||||
<view v-for="item in queryTypes" :key="item.value" class="query-tab" :class="{ 'is-active': activeQueryType === item.value }" @tap="activeQueryType = item.value">
|
||||
<view v-for="item in queryTypes" :key="item.value" class="query-tab" :class="{ 'is-active': activeQueryType === item.value }" @tap="handleRecordTypeChange(item.value)">
|
||||
<u-icon :name="item.icon" :color="activeQueryType === item.value ? '#ffffff' : '#9aa4b4'" size="28" />
|
||||
<text>{{ item.label }}</text>
|
||||
</view>
|
||||
@@ -35,17 +35,20 @@
|
||||
</view>
|
||||
|
||||
<view v-if="showAdvanced" class="advanced-panel">
|
||||
<view class="filter-label">工序</view>
|
||||
<view class="chip-row compact-chip-row">
|
||||
<view v-for="item in processOptions" :key="item.value" class="chip" :class="{ 'is-active': activeProcess === item.value }" @tap="activeProcess = item.value">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
<view class="filter-select-row" @tap="showDatePicker = true">
|
||||
<text class="filter-input-label">时间区间</text>
|
||||
<text class="filter-select-value">{{ dateRangeLabel }}</text>
|
||||
<u-icon name="arrow-right" color="#a0a7b5" size="24" />
|
||||
</view>
|
||||
<view class="filter-label status-filter-label">状态</view>
|
||||
<view class="chip-row compact-chip-row">
|
||||
<view v-for="item in statusOptions" :key="item.value" class="chip" :class="{ 'is-active': activeStatus === item.value }" @tap="activeStatus = item.value">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
<view class="filter-select-row" @tap="chooseProcess">
|
||||
<text class="filter-input-label">工序</text>
|
||||
<text class="filter-select-value">{{ processLabel }}</text>
|
||||
<u-icon name="arrow-right" color="#a0a7b5" size="24" />
|
||||
</view>
|
||||
<view class="filter-select-row" :class="{ 'is-disabled': !activeProcess }" @tap="chooseStep">
|
||||
<text class="filter-input-label">步骤</text>
|
||||
<text class="filter-select-value">{{ stepLabel }}</text>
|
||||
<u-icon name="arrow-right" color="#a0a7b5" size="24" />
|
||||
</view>
|
||||
<view class="filter-actions">
|
||||
<text @tap="resetFilter">重置条件</text>
|
||||
@@ -54,12 +57,13 @@
|
||||
</view>
|
||||
|
||||
<view class="result-header">
|
||||
<text>找到 {{ filteredRecords.length }} 条结果</text>
|
||||
<text>{{ hasSearched ? `找到 ${recordTotal} 条结果` : '请输入条件后点击查询' }}</text>
|
||||
<text>{{ currentTypeLabel }}</text>
|
||||
</view>
|
||||
<view v-if="hasSearched && recordLoading" class="query-loading">正在查询,请稍候…</view>
|
||||
|
||||
<view v-if="filteredRecords.length" class="record-list">
|
||||
<view v-for="item in filteredRecords" :key="item.id" class="record-card compact-record" @tap="showRecordDetail(item)">
|
||||
<view v-if="hasSearched && filteredRecords.length && !recordLoading" class="record-list">
|
||||
<view v-for="item in filteredRecords" :key="item.id" class="record-card compact-record" @tap="showRecordDetail(item)" @click="showRecordDetail(item)">
|
||||
<view class="record-top">
|
||||
<view class="record-number">
|
||||
<u-icon name="list" color="#44c653" size="28" />
|
||||
@@ -69,7 +73,7 @@
|
||||
</view>
|
||||
<view class="record-detail-grid">
|
||||
<view class="record-detail"><text>岗位:</text><text>{{ item.position }}</text></view>
|
||||
<view class="record-detail"><text>工序:</text><text>{{ item.process }}</text></view>
|
||||
<view class="record-detail"><text>步骤:</text><text>{{ item.process }} / {{ item.step }}</text></view>
|
||||
<view class="record-detail"><text>状态:</text><text class="record-status" :class="item.statusClass">{{ item.statusLabel }}</text></view>
|
||||
<view class="record-detail"><text>操作员:</text><text>{{ item.operator }}</text></view>
|
||||
</view>
|
||||
@@ -79,7 +83,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<u-empty v-else mode="data" text="暂无查询结果" />
|
||||
<u-empty v-else-if="hasSearched && !recordLoading" mode="data" text="暂无查询结果" />
|
||||
|
||||
<view v-if="detailVisible && selectedRecord" class="detail-mask" @tap="closeRecordDetail">
|
||||
<view class="detail-sheet" @tap.stop>
|
||||
@@ -102,6 +106,7 @@
|
||||
<view class="sheet-remark"><text>备注</text><text>{{ selectedRecord.remark }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<u-calendar v-model="showDatePicker" mode="range" @change="handleDateChange" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -112,132 +117,65 @@ import { openCodeScan } from '@/uni_modules/mas-code-scan'
|
||||
// #endif
|
||||
|
||||
import { getScanRegionSetting } from '@/utils/scan-settings.js'
|
||||
import { queryProduceRecords } from '@/api/produce/query.js'
|
||||
import { getProcessOptions, getStepOptions } from '@/api/produce/data-entry.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
keyword: '',
|
||||
orderNo: '',
|
||||
userCode: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
showDatePicker: false,
|
||||
pdaMode: false,
|
||||
activeQueryType: 'all',
|
||||
showAdvanced: false,
|
||||
isScanning: false,
|
||||
activeProcess: 'all',
|
||||
activeStatus: 'all',
|
||||
hasSearched: false,
|
||||
recordLoading: false,
|
||||
recordTotal: 0,
|
||||
recordPage: 1,
|
||||
recordLimit: 20,
|
||||
activeProcess: '',
|
||||
activeStep: '',
|
||||
detailVisible: false,
|
||||
selectedRecord: null,
|
||||
queryTypes: [
|
||||
{ label: '全部', value: 'all', icon: 'search' },
|
||||
{ label: '扫码记录', value: 'scan', icon: 'scan' },
|
||||
{ label: '批量记录', value: 'batch', icon: 'grid-fill' },
|
||||
{ label: '异常记录', value: 'abnormal', icon: 'warning-fill' }
|
||||
{ label: '订单', value: 'scan', icon: 'scan' },
|
||||
{ label: '整机', value: 'product', icon: 'grid-fill' },
|
||||
{ label: '异常', value: 'exception', icon: 'warning-fill' },
|
||||
{ label: '维修', value: 'repair', icon: 'wrench' }
|
||||
],
|
||||
processOptions: [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '总装', value: '总装' },
|
||||
{ label: '质检', value: '质检' },
|
||||
{ label: '包装', value: '包装' },
|
||||
{ label: '组装', value: '组装' }
|
||||
],
|
||||
statusOptions: [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '失败', value: 'failed' }
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
queryType: 'scan',
|
||||
typeLabel: '扫码记录',
|
||||
mainNo: 'SO20260808001',
|
||||
position: 'L1C-H',
|
||||
process: '总装',
|
||||
step: '开始',
|
||||
operator: 'U001',
|
||||
status: 'success',
|
||||
statusLabel: '成功',
|
||||
statusClass: 'is-success',
|
||||
scanTime: '2026-08-08 18:42:13',
|
||||
remark: '录入正常',
|
||||
modeLabel: '订单模式',
|
||||
productCode: '',
|
||||
quantity: ''
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
queryType: 'scan',
|
||||
typeLabel: '扫码记录',
|
||||
mainNo: 'MO20260808004',
|
||||
position: 'L1W-A',
|
||||
process: '质检',
|
||||
step: '完成',
|
||||
operator: 'U003',
|
||||
status: 'success',
|
||||
statusLabel: '成功',
|
||||
statusClass: 'is-success',
|
||||
scanTime: '2026-08-08 18:31:05',
|
||||
remark: '整机数据已提交',
|
||||
modeLabel: '整机模式',
|
||||
productCode: 'P10086',
|
||||
quantity: '2'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
queryType: 'abnormal',
|
||||
typeLabel: '异常记录',
|
||||
mainNo: 'SO20260808005',
|
||||
position: 'L1Z-K',
|
||||
process: '包装',
|
||||
step: '复核',
|
||||
operator: 'U002',
|
||||
status: 'failed',
|
||||
statusLabel: '失败',
|
||||
statusClass: 'is-failed',
|
||||
scanTime: '2026-08-08 18:12:44',
|
||||
remark: '订单号格式不符合规则',
|
||||
modeLabel: '订单模式',
|
||||
productCode: '',
|
||||
quantity: ''
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
queryType: 'batch',
|
||||
typeLabel: '批量记录',
|
||||
mainNo: 'MO20260808006',
|
||||
position: 'L1P-D',
|
||||
process: '总装',
|
||||
step: '开始',
|
||||
operator: 'U004',
|
||||
status: 'success',
|
||||
statusLabel: '成功',
|
||||
statusClass: 'is-success',
|
||||
scanTime: '2026-08-08 17:58:07',
|
||||
remark: '录入正常',
|
||||
modeLabel: '整机模式',
|
||||
productCode: 'P10092',
|
||||
quantity: '1'
|
||||
}
|
||||
]
|
||||
processOptions: [],
|
||||
stepOptions: [],
|
||||
records: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredRecords() {
|
||||
const keyword = this.keyword.trim().toLowerCase()
|
||||
return this.records.filter((item) => {
|
||||
const matchKeyword = !keyword
|
||||
|| String(item.mainNo).toLowerCase().includes(keyword)
|
||||
|| String(item.position || '').toLowerCase().includes(keyword)
|
||||
|| String(item.productCode || '').toLowerCase().includes(keyword)
|
||||
|| String(item.operator).toLowerCase().includes(keyword)
|
||||
const matchType = this.activeQueryType === 'all' || item.queryType === this.activeQueryType
|
||||
const matchProcess = this.activeProcess === 'all' || item.process === this.activeProcess
|
||||
const matchStatus = this.activeStatus === 'all' || item.status === this.activeStatus
|
||||
return matchKeyword && matchType && matchProcess && matchStatus
|
||||
const matchProcess = !this.activeProcess || item.processId === String(this.activeProcess) || item.process === this.processLabel
|
||||
const matchStep = !this.activeStep || item.stepId === String(this.activeStep) || item.step === this.stepLabel
|
||||
return matchType && matchProcess && matchStep
|
||||
})
|
||||
},
|
||||
currentTypeLabel() {
|
||||
const current = this.queryTypes.find((item) => item.value === this.activeQueryType)
|
||||
return current ? current.label : '全部类型'
|
||||
},
|
||||
processLabel() {
|
||||
return this.processOptions.find((item) => String(item.value) === String(this.activeProcess))?.label || '全部工序'
|
||||
},
|
||||
stepLabel() {
|
||||
return this.stepOptions.find((item) => String(item.value) === String(this.activeStep))?.label || '全部步骤'
|
||||
},
|
||||
dateRangeLabel() {
|
||||
return this.startDate && this.endDate ? `${this.startDate} 至 ${this.endDate}` : '不限时间'
|
||||
},
|
||||
successCount() {
|
||||
return this.filteredRecords.filter((item) => item.status === 'success').length
|
||||
},
|
||||
@@ -248,19 +186,147 @@ export default {
|
||||
watch: {
|
||||
filteredRecords: {
|
||||
immediate: true,
|
||||
handler(list) {
|
||||
if (!list.length) {
|
||||
this.selectedRecord = null
|
||||
this.detailVisible = false
|
||||
return
|
||||
}
|
||||
if (!this.selectedRecord || !list.some((item) => item.id === this.selectedRecord.id)) {
|
||||
this.selectedRecord = list[0]
|
||||
}
|
||||
handler(list) {
|
||||
if (!list.length) {
|
||||
this.selectedRecord = null
|
||||
this.detailVisible = false
|
||||
return
|
||||
}
|
||||
if (!this.selectedRecord || !list.some((item) => item.id === this.selectedRecord.id)) {
|
||||
this.detailVisible = false
|
||||
this.selectedRecord = list[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isLoggedIn() {
|
||||
return Boolean(uni.getStorageSync('scanapp_token'))
|
||||
},
|
||||
requireLogin() {
|
||||
if (this.isLoggedIn()) return true
|
||||
uni.showToast({ title: '请先登录后再查询', icon: 'none' })
|
||||
return false
|
||||
},
|
||||
mapRecord(item) {
|
||||
const type = item.record_type || 'scan'
|
||||
const resultName = String(item.result_name || '').trim()
|
||||
const statusName = String(item.status_name || '').trim()
|
||||
const status = resultName === '成功' ? 'success' : resultName === '失败' ? 'failed' : this.mapStatus(statusName)
|
||||
return {
|
||||
id: `${type}-${item.id}`,
|
||||
queryType: type,
|
||||
typeLabel: item.record_type_name || '记录',
|
||||
mainNo: item.order_no || item.repair_no || '-',
|
||||
position: item.position || '-',
|
||||
process: item.process_name || '-',
|
||||
step: item.step_name || '-',
|
||||
processId: item.process_id || '',
|
||||
stepId: item.step_id || '',
|
||||
operator: item.operator || item.user_code || '未记录',
|
||||
userCode: item.user_code || '',
|
||||
status,
|
||||
statusLabel: resultName || statusName || '已记录',
|
||||
statusClass: status === 'failed' ? 'is-failed' : status === 'success' ? 'is-success' : '',
|
||||
scanTime: item.event_date || this.formatEventDate(item.event_at),
|
||||
remark: item.remark || '',
|
||||
modeLabel: item.record_type_name || '记录',
|
||||
productCode: item.product_code || '',
|
||||
quantity: item.quantity ?? '',
|
||||
exceptionReason: item.exception_reason || '',
|
||||
exceptionContent: item.exception_content || '',
|
||||
materialCategory: item.material_category || '',
|
||||
faultDescription: item.fault_description || ''
|
||||
}
|
||||
},
|
||||
formatEventDate(timestamp) {
|
||||
const value = Number(timestamp || 0)
|
||||
if (!value) return '-'
|
||||
const date = new Date(value * 1000)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const pad = (part) => String(part).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
},
|
||||
mapStatus(statusName) {
|
||||
const statusMap = { 待处理: 'pending', 处理中: 'processing', 已完成: 'done', 已维修: 'repaired' }
|
||||
return statusMap[statusName] || statusName || 'recorded'
|
||||
},
|
||||
async loadRecords() {
|
||||
if (this.recordLoading) return
|
||||
this.detailVisible = false
|
||||
this.selectedRecord = null
|
||||
this.recordLoading = true
|
||||
const types = this.activeQueryType === 'all' ? ['scan', 'product', 'exception', 'repair'] : [this.activeQueryType]
|
||||
const params = {
|
||||
keyword: this.keyword.trim(),
|
||||
order_no: this.orderNo.trim(),
|
||||
user_code: this.userCode.trim(),
|
||||
start_date: this.startDate,
|
||||
end_date: this.endDate,
|
||||
process_id: this.activeProcess,
|
||||
step_id: this.activeStep,
|
||||
page: this.recordPage,
|
||||
limit: this.recordLimit
|
||||
}
|
||||
try {
|
||||
const responses = await Promise.all(types.map((recordType) => queryProduceRecords({ ...params, record_type: recordType })))
|
||||
const items = responses.flatMap((data) => Array.isArray(data?.items) ? data.items.map((item) => this.mapRecord(item)) : [])
|
||||
items.sort((a, b) => String(b.scanTime).localeCompare(String(a.scanTime)))
|
||||
this.records = items
|
||||
this.recordTotal = responses.reduce((total, data) => total + Number(data?.total || 0), 0)
|
||||
} catch (error) {
|
||||
this.records = []
|
||||
this.recordTotal = 0
|
||||
uni.showToast({ title: '查询失败,请稍后重试', icon: 'none' })
|
||||
} finally {
|
||||
this.recordLoading = false
|
||||
}
|
||||
},
|
||||
async loadProcessOptions() {
|
||||
try {
|
||||
const options = await getProcessOptions()
|
||||
this.processOptions = Array.isArray(options) ? options : []
|
||||
} catch (error) {
|
||||
this.processOptions = []
|
||||
}
|
||||
},
|
||||
chooseProcess() {
|
||||
const labels = ['全部工序', ...this.processOptions.map((item) => item.label)]
|
||||
uni.showActionSheet({ itemList: labels, success: async (result) => {
|
||||
this.activeProcess = result.tapIndex === 0 ? '' : this.processOptions[result.tapIndex - 1]?.value || ''
|
||||
this.activeStep = ''
|
||||
this.stepOptions = []
|
||||
if (this.activeProcess) {
|
||||
try {
|
||||
const options = await getStepOptions(this.activeProcess)
|
||||
this.stepOptions = Array.isArray(options) ? options : []
|
||||
} catch (error) {
|
||||
this.stepOptions = []
|
||||
}
|
||||
}
|
||||
} })
|
||||
},
|
||||
chooseStep() {
|
||||
if (!this.activeProcess) {
|
||||
uni.showToast({ title: '请先选择工序', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const labels = ['全部步骤', ...this.stepOptions.map((item) => item.label)]
|
||||
uni.showActionSheet({ itemList: labels, success: (result) => {
|
||||
this.activeStep = result.tapIndex === 0 ? '' : this.stepOptions[result.tapIndex - 1]?.value || ''
|
||||
} })
|
||||
},
|
||||
handleDateChange(result) {
|
||||
this.startDate = result.startDate || ''
|
||||
this.endDate = result.endDate || ''
|
||||
this.showDatePicker = false
|
||||
},
|
||||
handleRecordTypeChange(type) {
|
||||
if (this.activeQueryType === type) return
|
||||
this.activeQueryType = type
|
||||
this.recordPage = 1
|
||||
if (this.hasSearched) this.loadRecords()
|
||||
},
|
||||
getCurrentUserCode() {
|
||||
const user = uni.getStorageSync('scanapp_user') || {}
|
||||
return user.username || user.nickname || 'U001'
|
||||
@@ -282,23 +348,36 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
handleSearch() {
|
||||
if (!this.filteredRecords.length) {
|
||||
const hasInput = this.keyword.trim() || this.orderNo.trim() || this.userCode.trim()
|
||||
if (!hasInput) {
|
||||
this.hasSearched = false
|
||||
this.records = []
|
||||
this.recordTotal = 0
|
||||
this.selectedRecord = null
|
||||
this.detailVisible = false
|
||||
uni.showToast({ title: '请输入查询内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!this.selectedRecord || !this.filteredRecords.some((item) => item.id === this.selectedRecord.id)) {
|
||||
this.selectedRecord = this.filteredRecords[0]
|
||||
}
|
||||
this.recordPage = 1
|
||||
this.hasSearched = true
|
||||
this.loadRecords()
|
||||
},
|
||||
resetFilter() {
|
||||
this.keyword = ''
|
||||
this.orderNo = ''
|
||||
this.userCode = ''
|
||||
this.startDate = ''
|
||||
this.endDate = ''
|
||||
this.activeQueryType = 'all'
|
||||
this.activeProcess = 'all'
|
||||
this.activeStatus = 'all'
|
||||
this.activeProcess = ''
|
||||
this.activeStep = ''
|
||||
this.stepOptions = []
|
||||
this.showAdvanced = false
|
||||
this.detailVisible = false
|
||||
this.selectedRecord = this.records[0] || null
|
||||
this.recordPage = 1
|
||||
this.hasSearched = false
|
||||
this.records = []
|
||||
this.recordTotal = 0
|
||||
},
|
||||
scanKeyword() {
|
||||
if (this.isScanning) return
|
||||
@@ -334,6 +413,7 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
showRecordDetail(item) {
|
||||
if (!item) return
|
||||
this.selectedRecord = item
|
||||
this.detailVisible = true
|
||||
},
|
||||
@@ -345,11 +425,17 @@ export default {
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
if (!this.requireLogin()) return
|
||||
this.loadPdaMode()
|
||||
this.selectedRecord = this.records[0] || null
|
||||
this.loadProcessOptions()
|
||||
},
|
||||
onShow() {
|
||||
if (!this.isLoggedIn()) return
|
||||
this.loadPdaMode()
|
||||
},
|
||||
onHide() {
|
||||
this.detailVisible = false
|
||||
this.selectedRecord = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -651,7 +737,7 @@ export default {
|
||||
|
||||
.query-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
@@ -772,6 +858,53 @@ export default {
|
||||
box-shadow: 0 8rpx 20rpx rgba(23, 35, 61, 0.04);
|
||||
}
|
||||
|
||||
.filter-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 64rpx;
|
||||
margin-bottom: 12rpx;
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid #e3e7ef;
|
||||
border-radius: 12rpx;
|
||||
background: #f6f8fb;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-input-label {
|
||||
width: 112rpx;
|
||||
flex: none;
|
||||
color: #5f6b7a;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.filter-input-row :deep(.u-input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 64rpx;
|
||||
margin-bottom: 12rpx;
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid #e3e7ef;
|
||||
border-radius: 12rpx;
|
||||
background: #f6f8fb;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-select-row.is-disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.filter-select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #2b313b;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.status-filter-label {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
@@ -819,6 +952,13 @@ export default {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.query-loading {
|
||||
padding: 28rpx 0;
|
||||
color: #7d8798;
|
||||
font-size: 20rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
@@ -909,10 +1049,16 @@ export default {
|
||||
|
||||
.detail-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
@@ -1018,6 +1164,8 @@ export default {
|
||||
|
||||
.theme-dark .query-tab:not(.is-active),
|
||||
.theme-dark .query-input-wrap,
|
||||
.theme-dark .filter-input-row,
|
||||
.theme-dark .filter-select-row,
|
||||
.theme-dark .chip,
|
||||
.theme-dark .record-info,
|
||||
.theme-dark .sheet-detail-item,
|
||||
@@ -1028,6 +1176,7 @@ export default {
|
||||
|
||||
.theme-dark .query-title,
|
||||
.theme-dark .advanced-title,
|
||||
.theme-dark .filter-select-value,
|
||||
.theme-dark .record-main,
|
||||
.theme-dark .record-detail text:last-child,
|
||||
.theme-dark .detail-sheet-header,
|
||||
@@ -1055,4 +1204,178 @@ export default {
|
||||
.theme-dark :deep(.u-input__input) {
|
||||
color: #f2f5fa !important;
|
||||
}
|
||||
|
||||
/* 查询页统一视觉层级:保持移动端紧凑、清晰的生产工具风格。 */
|
||||
.query-page.query-layout {
|
||||
padding: 24rpx 24rpx 56rpx;
|
||||
background: linear-gradient(180deg, #edf5ff 0%, #f5f7fb 30%, #f5f7fb 100%);
|
||||
}
|
||||
|
||||
.query-header {
|
||||
padding: 24rpx 22rpx;
|
||||
border: 1rpx solid #e5ebf4;
|
||||
border-radius: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 12rpx 30rpx rgba(39, 74, 125, 0.08);
|
||||
}
|
||||
|
||||
.query-title {
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.query-tabs {
|
||||
display: flex;
|
||||
gap: 10rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 8rpx;
|
||||
border: 1rpx solid #e5ebf4;
|
||||
border-radius: 18rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8rpx 22rpx rgba(39, 74, 125, 0.05);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.query-tab {
|
||||
min-width: 120rpx;
|
||||
flex: 1;
|
||||
flex-shrink: 0;
|
||||
border-radius: 12rpx;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.query-search-row {
|
||||
margin-top: 18rpx;
|
||||
padding: 10rpx;
|
||||
border-radius: 18rpx;
|
||||
box-shadow: 0 10rpx 26rpx rgba(39, 74, 125, 0.07);
|
||||
}
|
||||
|
||||
.query-input-wrap {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.camera-button,
|
||||
.query-button {
|
||||
box-shadow: 0 8rpx 18rpx rgba(47, 108, 246, 0.2);
|
||||
}
|
||||
|
||||
.advanced-trigger,
|
||||
.advanced-panel {
|
||||
box-shadow: 0 8rpx 22rpx rgba(39, 74, 125, 0.05);
|
||||
}
|
||||
|
||||
.advanced-trigger {
|
||||
margin-top: 16rpx;
|
||||
padding: 20rpx 18rpx;
|
||||
}
|
||||
|
||||
.advanced-panel {
|
||||
padding: 20rpx 18rpx 18rpx;
|
||||
}
|
||||
|
||||
.filter-select-row {
|
||||
margin-bottom: 14rpx;
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.filter-input-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
margin-top: 24rpx;
|
||||
padding: 0 8rpx 10rpx;
|
||||
color: #738199;
|
||||
}
|
||||
|
||||
.record-card.compact-record {
|
||||
margin-top: 12rpx;
|
||||
padding: 22rpx 20rpx 18rpx;
|
||||
border-radius: 18rpx;
|
||||
box-shadow: 0 10rpx 24rpx rgba(39, 74, 125, 0.06);
|
||||
}
|
||||
|
||||
.record-detail-grid {
|
||||
gap: 10rpx 16rpx;
|
||||
margin-top: 18rpx;
|
||||
padding-top: 16rpx;
|
||||
}
|
||||
|
||||
.record-detail text:last-child {
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-footer {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.theme-dark.query-page.query-layout {
|
||||
background: #151923;
|
||||
}
|
||||
|
||||
.theme-dark .query-header,
|
||||
.theme-dark .query-tabs {
|
||||
border-color: #364152;
|
||||
background: #222936;
|
||||
}
|
||||
|
||||
.theme-dark .query-search-row,
|
||||
.theme-dark .advanced-trigger,
|
||||
.theme-dark .advanced-panel,
|
||||
.theme-dark .record-card.compact-record {
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
/* 防止记录类型标签换行,并让输入文字在控件内垂直居中。 */
|
||||
.query-tab {
|
||||
min-width: 0;
|
||||
padding: 0 4rpx;
|
||||
flex: 1 1 0;
|
||||
margin-right: 10rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query-tabs {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.query-tab:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.query-tab text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.camera-button,
|
||||
.query-button {
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.camera-button {
|
||||
width: 80rpx;
|
||||
}
|
||||
|
||||
.query-button {
|
||||
width: 128rpx;
|
||||
}
|
||||
|
||||
.query-input-wrap :deep(.u-input) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.query-input-wrap :deep(.u-input__input) {
|
||||
height: 100%;
|
||||
line-height: 80rpx;
|
||||
}
|
||||
</style>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user