增加了手机操作端
This commit is contained in:
17
work-app/src/App.vue
Normal file
17
work-app/src/App.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow } from "@dcloudio/uni-app";
|
||||
import { ensureAuthenticatedPageAccess } from "./utils/auth";
|
||||
|
||||
onLaunch(() => {
|
||||
ensureAuthenticatedPageAccess();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
ensureAuthenticatedPageAccess();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "./styles/tokens.scss";
|
||||
@use "./styles/app.scss";
|
||||
</style>
|
||||
447
work-app/src/api/admin.ts
Normal file
447
work-app/src/api/admin.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import { request, uploadFile } from "../utils/request";
|
||||
import type { AdminSessionInfo } from "../utils/auth";
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
token: string;
|
||||
admin_info: AdminSessionInfo;
|
||||
}
|
||||
|
||||
export interface AdminFileAsset {
|
||||
file_id: string;
|
||||
file_url: string;
|
||||
thumbnail_url: string;
|
||||
name?: string;
|
||||
file_type?: string;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedList<T> {
|
||||
list: T[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface AdminOrderListItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
service_provider: string;
|
||||
service_provider_text: string;
|
||||
source_channel: string;
|
||||
source_channel_text: string;
|
||||
source_customer_id: string;
|
||||
order_status: string;
|
||||
display_status: string;
|
||||
warehouse_bucket?: string;
|
||||
warehouse_bucket_text?: string;
|
||||
estimated_finish_time: string;
|
||||
pay_amount: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
order_info: AdminOrderListItem & {
|
||||
can_mark_received: boolean;
|
||||
can_submit_return_logistics: boolean;
|
||||
return_logistics_block_reason: string;
|
||||
};
|
||||
product_info: {
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
color: string;
|
||||
size_spec: string;
|
||||
serial_no: string;
|
||||
};
|
||||
extra_info: Record<string, any>;
|
||||
shipping_target: null | Record<string, any>;
|
||||
return_address: null | {
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
full_address: string;
|
||||
};
|
||||
logistics_info: null | Record<string, any>;
|
||||
return_logistics: null | Record<string, any>;
|
||||
supplement_task: null | Record<string, any>;
|
||||
report_summary: null | {
|
||||
id?: number;
|
||||
report_no: string;
|
||||
report_title: string;
|
||||
report_status: string;
|
||||
report_status_text?: string;
|
||||
publish_time: string;
|
||||
};
|
||||
timeline: Array<{
|
||||
node_text: string;
|
||||
node_desc: string;
|
||||
occurred_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AdminWarehouseWorkbenchContext {
|
||||
order_info: {
|
||||
id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
service_provider: string;
|
||||
service_provider_text: string;
|
||||
source_channel: string;
|
||||
source_channel_text: string;
|
||||
source_customer_id: string;
|
||||
order_status: string;
|
||||
display_status: string;
|
||||
};
|
||||
product_info: {
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
color: string;
|
||||
size_spec: string;
|
||||
serial_no: string;
|
||||
};
|
||||
logistics_info: null | {
|
||||
express_company: string;
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
};
|
||||
return_address: null | {
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
full_address: string;
|
||||
};
|
||||
return_logistics: null | {
|
||||
express_company: string;
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
};
|
||||
transfer_flow: null | {
|
||||
internal_tag_no: string;
|
||||
current_stage: string;
|
||||
current_stage_text: string;
|
||||
current_location: string;
|
||||
current_location_text: string;
|
||||
return_confirmed_at?: string;
|
||||
};
|
||||
report_info: null | {
|
||||
id: number;
|
||||
report_no: string;
|
||||
report_status: string;
|
||||
publish_time: string;
|
||||
zhongjian_report_no: string;
|
||||
zhongjian_report_files: AdminFileAsset[];
|
||||
};
|
||||
flow_logs?: Array<{
|
||||
id: number;
|
||||
action_text: string;
|
||||
operator_name: string;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
next_action?: string;
|
||||
next_action_text?: string;
|
||||
}
|
||||
|
||||
export interface AdminAppraisalTaskListItem {
|
||||
id: number;
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
external_order_no: string;
|
||||
service_provider: string;
|
||||
service_provider_text: string;
|
||||
task_stage: string;
|
||||
task_stage_text: string;
|
||||
status: string;
|
||||
status_text: string;
|
||||
assignee_id: number;
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
assignee_name: string;
|
||||
result_text: string;
|
||||
started_at: string;
|
||||
submitted_at: string;
|
||||
sla_deadline: string;
|
||||
is_overtime: boolean;
|
||||
display_status: string;
|
||||
}
|
||||
|
||||
export interface AdminAppraisalTaskDetail {
|
||||
task_info: AdminAppraisalTaskListItem & {
|
||||
is_overtime: boolean;
|
||||
};
|
||||
report_summary: null | {
|
||||
id: number;
|
||||
report_no: string;
|
||||
report_status: string;
|
||||
report_status_text: string;
|
||||
};
|
||||
material_tag?: null | Record<string, any>;
|
||||
product_info: {
|
||||
product_name: string;
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
brand_id: number;
|
||||
brand_name: string;
|
||||
color: string;
|
||||
size_spec: string;
|
||||
serial_no: string;
|
||||
};
|
||||
extra_info: Record<string, any>;
|
||||
result_info: {
|
||||
result_text: string;
|
||||
result_desc: string;
|
||||
condition_grade: string;
|
||||
condition_desc?: string;
|
||||
valuation_min: number;
|
||||
valuation_max: number;
|
||||
valuation_desc?: string;
|
||||
attachments: AdminFileAsset[];
|
||||
external_remark: string;
|
||||
internal_remark: string;
|
||||
key_points: Array<{
|
||||
point_code: string;
|
||||
point_name: string;
|
||||
point_value: string;
|
||||
point_remark: string;
|
||||
}>;
|
||||
};
|
||||
prefill_result_info?: null | (AdminAppraisalTaskDetail["result_info"] & {
|
||||
source_task_id?: number;
|
||||
source_stage?: string;
|
||||
source_stage_text?: string;
|
||||
});
|
||||
appraisal_template: null | {
|
||||
id?: number;
|
||||
name?: string;
|
||||
code?: string;
|
||||
service_provider?: string;
|
||||
service_provider_text?: string;
|
||||
result_options: string[];
|
||||
condition_options: string[];
|
||||
valuation_hint?: string;
|
||||
key_points: Array<{
|
||||
point_code: string;
|
||||
point_name: string;
|
||||
point_type: "text" | "textarea" | "select" | "boolean";
|
||||
options: string[];
|
||||
sort_order?: number;
|
||||
is_required: boolean;
|
||||
point_value: string;
|
||||
point_remark: string;
|
||||
}>;
|
||||
};
|
||||
stage_tasks?: Array<AdminAppraisalTaskListItem & { is_current?: boolean }>;
|
||||
timeline: Array<{
|
||||
node_text: string;
|
||||
node_desc: string;
|
||||
occurred_at: string;
|
||||
}>;
|
||||
materials: Array<{
|
||||
item_name: string;
|
||||
status: string;
|
||||
source_type: string;
|
||||
files: Array<{
|
||||
file_id: string;
|
||||
file_url: string;
|
||||
thumbnail_url: string;
|
||||
}>;
|
||||
}>;
|
||||
supplement_task: null | {
|
||||
id: number;
|
||||
reason: string;
|
||||
deadline: string;
|
||||
status: string;
|
||||
items: Array<{
|
||||
item_name: string;
|
||||
guide_text: string;
|
||||
is_required: boolean;
|
||||
}>;
|
||||
};
|
||||
zhongjian_report: {
|
||||
report_no: string;
|
||||
files: AdminFileAsset[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminReportListItem {
|
||||
id: number;
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
report_no: string;
|
||||
report_type: string;
|
||||
report_type_text: string;
|
||||
report_title: string;
|
||||
report_status: string;
|
||||
report_status_text: string;
|
||||
service_provider: string;
|
||||
service_provider_text: string;
|
||||
institution_name: string;
|
||||
publish_time: string;
|
||||
zhongjian_report_no: string;
|
||||
report_entry_admin_name: string;
|
||||
report_entered_at: string;
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
}
|
||||
|
||||
export interface AdminReportDetail {
|
||||
report_header: Partial<AdminReportListItem> & {
|
||||
id: number;
|
||||
order_id: number;
|
||||
report_no: string;
|
||||
report_type: string;
|
||||
report_type_text: string;
|
||||
report_title: string;
|
||||
report_status: string;
|
||||
report_status_text: string;
|
||||
service_provider: string;
|
||||
service_provider_text: string;
|
||||
institution_name: string;
|
||||
publish_time: string;
|
||||
zhongjian_report_no: string;
|
||||
report_entry_admin_id: number;
|
||||
report_entry_admin_name: string;
|
||||
report_entered_at: string;
|
||||
};
|
||||
product_info: Record<string, any>;
|
||||
result_info: Record<string, any>;
|
||||
appraisal_info: Record<string, any>;
|
||||
valuation_info: Record<string, any>;
|
||||
evidence_attachments: AdminFileAsset[];
|
||||
zhongjian_report_files: AdminFileAsset[];
|
||||
risk_notice_text: string;
|
||||
verify_info: {
|
||||
verify_status: string;
|
||||
verify_url: string;
|
||||
verify_qrcode_url: string;
|
||||
report_page_url: string;
|
||||
verify_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login(mobile: string, password: string) {
|
||||
return request<AdminLoginResponse>("/api/admin/auth/login", {
|
||||
method: "POST",
|
||||
data: { mobile, password },
|
||||
});
|
||||
},
|
||||
getAuthMe() {
|
||||
return request<{ admin_info: AdminSessionInfo }>("/api/admin/auth/me");
|
||||
},
|
||||
logout() {
|
||||
return request<Record<string, never>>("/api/admin/auth/logout", { method: "POST" });
|
||||
},
|
||||
getOrders(params?: Record<string, string | number>) {
|
||||
return request<PaginatedList<AdminOrderListItem>>("/api/admin/orders", { params });
|
||||
},
|
||||
getOrderDetail(id: number) {
|
||||
return request<AdminOrderDetail>("/api/admin/order/detail", { params: { id } });
|
||||
},
|
||||
lookupWarehouseInbound(trackingNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/lookup", {
|
||||
params: { tracking_no: trackingNo },
|
||||
});
|
||||
},
|
||||
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/receive", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
|
||||
params: { internal_tag_no: internalTagNo },
|
||||
});
|
||||
},
|
||||
zhongjianWarehouseOutbound(internalTagNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/outbound", {
|
||||
method: "POST",
|
||||
data: { internal_tag_no: internalTagNo },
|
||||
});
|
||||
},
|
||||
zhongjianWarehouseInbound(internalTagNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/inbound", {
|
||||
method: "POST",
|
||||
data: { internal_tag_no: internalTagNo },
|
||||
});
|
||||
},
|
||||
lookupWarehouseReturn(internalTagNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/lookup", {
|
||||
params: { internal_tag_no: internalTagNo },
|
||||
});
|
||||
},
|
||||
verifyWarehouseReturnMaterialTag(data: { internal_tag_no: string; qr_input: string }) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/material-tag/verify", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
confirmWarehouseReturnZhongjian(internalTagNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
|
||||
method: "POST",
|
||||
data: { internal_tag_no: internalTagNo },
|
||||
});
|
||||
},
|
||||
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string }) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
getAppraisalTasks(params?: Record<string, string | number>) {
|
||||
return request<PaginatedList<AdminAppraisalTaskListItem>>("/api/admin/appraisal-tasks", { params });
|
||||
},
|
||||
getAppraisalTaskDetail(id: number) {
|
||||
return request<AdminAppraisalTaskDetail>("/api/admin/appraisal-task/detail", { params: { id } });
|
||||
},
|
||||
scanAppraisalTransferTag(internalTagNo: string) {
|
||||
return request<{ task_id: number; order_id: number; service_provider: string; service_provider_text: string }>(
|
||||
"/api/admin/appraisal-task/transfer-tag/scan",
|
||||
{ method: "POST", data: { internal_tag_no: internalTagNo } },
|
||||
);
|
||||
},
|
||||
saveAppraisalTaskResult(data: Record<string, unknown>) {
|
||||
return request<{ id: number }>("/api/admin/appraisal-task/save-result", { method: "POST", data });
|
||||
},
|
||||
requestAppraisalTaskSupplement(data: Record<string, unknown>) {
|
||||
return request<{ id: number; supplement_task_id: number }>("/api/admin/appraisal-task/request-supplement", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
|
||||
return request<{ id: number; report: Record<string, any> }>("/api/admin/appraisal-task/zhongjian-report/save", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
publishAppraisalTaskWithMaterialTag(data: { id: number; qr_input: string }) {
|
||||
return request<Record<string, any>>("/api/admin/appraisal-task/material-tag/publish", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
uploadAppraisalEvidenceFile(filePath: string) {
|
||||
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath);
|
||||
},
|
||||
deleteAppraisalEvidenceFile(fileUrl: string) {
|
||||
return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", {
|
||||
method: "POST",
|
||||
data: { file_url: fileUrl },
|
||||
});
|
||||
},
|
||||
getReports(params?: Record<string, string | number>) {
|
||||
return request<PaginatedList<AdminReportListItem>>("/api/admin/reports", { params });
|
||||
},
|
||||
getReportDetail(id: number) {
|
||||
return request<AdminReportDetail>("/api/admin/report/detail", { params: { id } });
|
||||
},
|
||||
};
|
||||
8
work-app/src/env.d.ts
vendored
Normal file
8
work-app/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
9
work-app/src/main.ts
Normal file
9
work-app/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createSSRApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
app.use(createPinia());
|
||||
return { app };
|
||||
}
|
||||
43
work-app/src/manifest.json
Normal file
43
work-app/src/manifest.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "安心验作业端",
|
||||
"appid": "__UNI__E0C8390",
|
||||
"description": "安心验仓管与鉴定作业 Android App",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "101",
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"packagename": "com.anxinyan.work",
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
72
work-app/src/pages.json
Normal file
72
work-app/src/pages.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/auth/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "作业端登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scan/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "扫码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/work-order/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/task/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "鉴定工单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/report/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报告详情"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#707174",
|
||||
"selectedColor": "#202124",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/scan/index",
|
||||
"text": "扫码"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/work-order/index",
|
||||
"text": "工单"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mine/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "安心验作业端",
|
||||
"navigationBarBackgroundColor": "#F6F7F8",
|
||||
"backgroundColor": "#F6F7F8"
|
||||
}
|
||||
}
|
||||
224
work-app/src/pages/auth/login.vue
Normal file
224
work-app/src/pages/auth/login.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { adminApi } from "../../api/admin";
|
||||
import {
|
||||
availableWorkRoles,
|
||||
getAdminInfo,
|
||||
getSelectedWorkRole,
|
||||
hasAnyWorkPermission,
|
||||
isLoggedIn,
|
||||
navigateAfterLogin,
|
||||
roleText,
|
||||
setAdminInfo,
|
||||
setAdminToken,
|
||||
setSelectedWorkRole,
|
||||
type WorkRole,
|
||||
} from "../../utils/auth";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const submitting = ref(false);
|
||||
const redirect = ref("");
|
||||
const choosingRole = ref(false);
|
||||
const roleOptions = ref<WorkRole[]>([]);
|
||||
const form = reactive({
|
||||
mobile: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => form.mobile.trim() !== "" && form.password.trim() !== "");
|
||||
const roleOptionText = (role: WorkRole) => (role === "warehouse" ? "仓管作业" : "鉴定师作业");
|
||||
const roleOptionDesc = (role: WorkRole) =>
|
||||
role === "warehouse" ? "扫码入库、出库,并查看订单中心。" : "处理鉴定工单,上传图片和视频证据。";
|
||||
|
||||
function finishLoginWithRole(role: WorkRole) {
|
||||
if (!setSelectedWorkRole(role, getAdminInfo())) {
|
||||
showInfoToast("当前账号无此角色权限");
|
||||
return;
|
||||
}
|
||||
showInfoToast(`已选择${roleText(role)}`);
|
||||
navigateAfterLogin(redirect.value || "/pages/scan/index");
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return;
|
||||
if (!canSubmit.value) {
|
||||
showInfoToast("请输入手机号和密码");
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result = await withLoading("正在登录", () => adminApi.login(form.mobile.trim(), form.password.trim()));
|
||||
if (!hasAnyWorkPermission(result.admin_info)) {
|
||||
throw new Error("当前账号没有作业端权限");
|
||||
}
|
||||
const roles = availableWorkRoles(result.admin_info);
|
||||
setAdminToken(result.token);
|
||||
setAdminInfo(result.admin_info);
|
||||
if (roles.length > 1) {
|
||||
roleOptions.value = roles;
|
||||
choosingRole.value = true;
|
||||
showInfoToast("请选择本次登录角色");
|
||||
return;
|
||||
}
|
||||
if (roles[0]) {
|
||||
setSelectedWorkRole(roles[0], result.admin_info);
|
||||
}
|
||||
showInfoToast("登录成功");
|
||||
navigateAfterLogin(redirect.value || "/pages/scan/index");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "登录失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
redirect.value = String(options?.redirect || "");
|
||||
const info = getAdminInfo();
|
||||
if (isLoggedIn() && hasAnyWorkPermission(info)) {
|
||||
const roles = availableWorkRoles(info);
|
||||
const selectedRole = getSelectedWorkRole();
|
||||
if (roles.length > 1 && !selectedRole) {
|
||||
roleOptions.value = roles;
|
||||
choosingRole.value = true;
|
||||
return;
|
||||
}
|
||||
if (roles.length === 1 && roles[0]) {
|
||||
setSelectedWorkRole(roles[0], info);
|
||||
}
|
||||
navigateAfterLogin(redirect.value || "/pages/scan/index");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<view class="login-card">
|
||||
<view class="brand-row">
|
||||
<image class="brand-logo" src="/static/logo.png" mode="aspectFit" />
|
||||
<view>
|
||||
<view class="brand-name">安心验作业端</view>
|
||||
<view class="brand-desc">仓管与鉴定师移动作业</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-stack">
|
||||
<template v-if="!choosingRole">
|
||||
<input v-model="form.mobile" class="field" type="number" placeholder="管理员手机号" />
|
||||
<input v-model="form.password" class="field" type="password" placeholder="登录密码" @confirm="handleSubmit" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="role-title">选择本次登录角色</view>
|
||||
<view
|
||||
v-for="item in roleOptions"
|
||||
:key="item"
|
||||
class="role-option"
|
||||
@click="finishLoginWithRole(item)"
|
||||
>
|
||||
<view>
|
||||
<view class="role-name">{{ roleOptionText(item) }}</view>
|
||||
<view class="role-desc">{{ roleOptionDesc(item) }}</view>
|
||||
</view>
|
||||
<text class="role-arrow">进入</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<button v-if="!choosingRole" class="btn btn--primary login-submit" :disabled="submitting" @click="handleSubmit">
|
||||
{{ submitting ? "登录中" : "登录" }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
padding: 96rpx 32rpx 40rpx;
|
||||
background: var(--work-bg);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 42rpx 32rpx 34rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: 20rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--work-shadow);
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 16rpx;
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
color: var(--work-text);
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 18rpx;
|
||||
margin-top: 48rpx;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.role-title {
|
||||
color: var(--work-text);
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.role-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
min-height: 132rpx;
|
||||
padding: 22rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.role-name {
|
||||
color: var(--work-text);
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.role-desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.role-arrow {
|
||||
flex: 0 0 auto;
|
||||
color: var(--work-accent-deep);
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
195
work-app/src/pages/mine/index.vue
Normal file
195
work-app/src/pages/mine/index.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi } from "../../api/admin";
|
||||
import {
|
||||
APPRAISAL_PERMISSION,
|
||||
REPORT_PERMISSION,
|
||||
WAREHOUSE_PERMISSION,
|
||||
availableWorkRoles,
|
||||
getAdminInfo,
|
||||
hasPermission,
|
||||
logoutAndRedirect,
|
||||
resolveWorkRole,
|
||||
roleText,
|
||||
setAdminInfo,
|
||||
setSelectedWorkRole,
|
||||
type AdminSessionInfo,
|
||||
type WorkRole,
|
||||
} from "../../utils/auth";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const adminInfo = ref<AdminSessionInfo | null>(getAdminInfo());
|
||||
const currentRole = ref<WorkRole>(resolveWorkRole(adminInfo.value));
|
||||
|
||||
const currentRoleText = computed(() => roleText(currentRole.value));
|
||||
const workRoles = computed(() => availableWorkRoles(adminInfo.value));
|
||||
const canSwitchRole = computed(() => workRoles.value.length > 1);
|
||||
const permissionTags = computed(() => {
|
||||
const tags = [];
|
||||
if (hasPermission(WAREHOUSE_PERMISSION, adminInfo.value)) tags.push("仓管作业");
|
||||
if (hasPermission(APPRAISAL_PERMISSION, adminInfo.value)) tags.push("鉴定工单");
|
||||
if (hasPermission(REPORT_PERMISSION, adminInfo.value)) tags.push("报告查看");
|
||||
return tags;
|
||||
});
|
||||
const roleOptionText = (role: WorkRole) => (role === "warehouse" ? "仓管作业" : "鉴定师作业");
|
||||
|
||||
async function refreshMe() {
|
||||
try {
|
||||
const data = await adminApi.getAuthMe();
|
||||
adminInfo.value = data.admin_info;
|
||||
setAdminInfo(data.admin_info);
|
||||
currentRole.value = resolveWorkRole(data.admin_info);
|
||||
} catch (error) {
|
||||
showErrorToast(error, "账号信息加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function switchRole(role: WorkRole) {
|
||||
if (role === currentRole.value) return;
|
||||
if (!setSelectedWorkRole(role, adminInfo.value)) {
|
||||
showInfoToast("当前账号无此角色权限");
|
||||
return;
|
||||
}
|
||||
currentRole.value = role;
|
||||
showInfoToast(`已切换为${roleText(role)}`);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await withLoading("正在退出", () => adminApi.logout());
|
||||
} catch {
|
||||
// Token may already be invalid; local logout still needs to complete.
|
||||
}
|
||||
logoutAndRedirect();
|
||||
}
|
||||
|
||||
function copyMobile() {
|
||||
const mobile = adminInfo.value?.mobile || "";
|
||||
if (!mobile) return;
|
||||
uni.setClipboardData({
|
||||
data: mobile,
|
||||
success: () => showInfoToast("手机号已复制"),
|
||||
});
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
adminInfo.value = getAdminInfo();
|
||||
currentRole.value = resolveWorkRole(adminInfo.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="hero">
|
||||
<view class="eyebrow">当前角色:{{ currentRoleText }}</view>
|
||||
<view class="title">我的</view>
|
||||
<view class="subtitle">查看当前作业账号、权限和登录状态。</view>
|
||||
</view>
|
||||
|
||||
<view class="card profile-card">
|
||||
<view class="avatar">{{ (adminInfo?.name || "作").slice(0, 1) }}</view>
|
||||
<view class="profile-main">
|
||||
<view class="profile-name">{{ adminInfo?.name || "未登录" }}</view>
|
||||
<view class="profile-meta" @click="copyMobile">{{ adminInfo?.mobile || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="canSwitchRole" class="card">
|
||||
<view class="card-title">作业角色</view>
|
||||
<view class="role-switch segmented">
|
||||
<view
|
||||
v-for="item in workRoles"
|
||||
:key="item"
|
||||
:class="['segment', currentRole === item ? 'segment--active' : '']"
|
||||
@click="switchRole(item)"
|
||||
>
|
||||
{{ roleOptionText(item) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">权限</view>
|
||||
<view class="permission-list">
|
||||
<text v-for="item in permissionTags" :key="item" class="tag">{{ item }}</text>
|
||||
<text v-if="!permissionTags.length" class="tag tag--warning">暂无作业权限</text>
|
||||
</view>
|
||||
<view class="role-list">
|
||||
<view v-for="item in adminInfo?.role_names || []" :key="item" class="role-row">{{ item }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card action-card">
|
||||
<button class="btn" @click="refreshMe">刷新账号信息</button>
|
||||
<button class="btn btn--danger" @click="handleLogout">退出登录</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 20rpx;
|
||||
background: var(--work-accent);
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
line-height: 96rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
color: var(--work-text);
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
margin-top: 8rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.permission-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.role-switch {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.role-row {
|
||||
padding: 18rpx 20rpx;
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
color: var(--work-text-soft);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: grid;
|
||||
gap: 16rpx;
|
||||
}
|
||||
</style>
|
||||
215
work-app/src/pages/order/detail.vue
Normal file
215
work-app/src/pages/order/detail.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminOrderDetail } from "../../api/admin";
|
||||
import { showErrorToast } from "../../utils/feedback";
|
||||
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const detail = ref<AdminOrderDetail | null>(null);
|
||||
const orderId = ref(0);
|
||||
|
||||
const pageTitle = computed(() => detail.value?.order_info.order_no || "订单详情");
|
||||
|
||||
const timeline = computed(() => detail.value?.timeline || []);
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!orderId.value) return;
|
||||
loading.value = true;
|
||||
if (!pageReady.value) loadError.value = "";
|
||||
try {
|
||||
detail.value = await adminApi.getOrderDetail(orderId.value);
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "订单详情加载失败,请稍后重试。";
|
||||
}
|
||||
showErrorToast(error, "订单详情加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openReportDetail() {
|
||||
const reportId = Number(detail.value?.report_summary?.id || 0);
|
||||
if (!reportId) return;
|
||||
uni.navigateTo({ url: `/pages/report/detail?id=${reportId}` });
|
||||
}
|
||||
|
||||
function formatMoney(value?: number) {
|
||||
const amount = Number(value || 0);
|
||||
return `¥${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function displayAddress(address?: { consignee?: string; mobile?: string; full_address?: string } | null) {
|
||||
if (!address) return "-";
|
||||
return [address.consignee, address.mobile, address.full_address].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
orderId.value = Number(options?.id || 0);
|
||||
if (!orderId.value) {
|
||||
loadError.value = "缺少订单编号,无法查看详情。";
|
||||
}
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
if (orderId.value) {
|
||||
void fetchDetail();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="!pageReady && loading" class="empty">正在加载订单详情</view>
|
||||
<view v-else-if="!pageReady && loadError" class="empty">{{ loadError }}</view>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="hero">
|
||||
<view class="eyebrow">订单详情</view>
|
||||
<view class="title">{{ pageTitle }}</view>
|
||||
<view class="subtitle">{{ detail.order_info.appraisal_no }}</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row">
|
||||
<view>
|
||||
<view class="card-title">{{ detail.product_info.product_name || "待完善物品信息" }}</view>
|
||||
<view class="card-desc">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</view>
|
||||
</view>
|
||||
<text class="tag">{{ detail.order_info.display_status }}</text>
|
||||
</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">服务类型</view>
|
||||
<view class="meta-value">{{ detail.order_info.service_provider_text }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">订单金额</view>
|
||||
<view class="meta-value">{{ formatMoney(detail.order_info.pay_amount) }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">创建时间</view>
|
||||
<view class="meta-value">{{ detail.order_info.created_at || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">预计完成</view>
|
||||
<view class="meta-value">{{ detail.order_info.estimated_finish_time || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">物品信息</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">商品名称</view>
|
||||
<view class="meta-value">{{ detail.product_info.product_name || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">品类 / 品牌</view>
|
||||
<view class="meta-value">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">颜色 / 规格</view>
|
||||
<view class="meta-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">序列号</view>
|
||||
<view class="meta-value">{{ detail.product_info.serial_no || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">物流与寄回</view>
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">寄送到中心</view>
|
||||
<view class="meta-value">{{ detail.logistics_info ? `${detail.logistics_info.express_company || "-"} / ${detail.logistics_info.tracking_no || "-"}` : "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">寄回地址</view>
|
||||
<view class="meta-value">{{ displayAddress(detail.return_address) }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">回寄运单</view>
|
||||
<view class="meta-value">{{ detail.return_logistics ? `${detail.return_logistics.express_company || "-"} / ${detail.return_logistics.tracking_no || "-"}` : "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.report_summary" class="card">
|
||||
<view class="row">
|
||||
<view>
|
||||
<view class="card-title">报告摘要</view>
|
||||
<view class="card-desc">{{ detail.report_summary.report_status_text || detail.report_summary.report_status }}</view>
|
||||
</view>
|
||||
<button class="btn btn--ghost" @click="openReportDetail">查看报告</button>
|
||||
</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">报告编号</view>
|
||||
<view class="meta-value">{{ detail.report_summary.report_no }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">报告标题</view>
|
||||
<view class="meta-value">{{ detail.report_summary.report_title }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">发布时间</view>
|
||||
<view class="meta-value">{{ detail.report_summary.publish_time || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">流转时间线</view>
|
||||
<view class="timeline">
|
||||
<view v-for="item in timeline" :key="`${item.node_text}-${item.occurred_at}`" class="timeline-item">
|
||||
<view class="timeline-item__head">
|
||||
<view class="timeline-item__title">{{ item.node_text }}</view>
|
||||
<view class="timeline-item__time">{{ item.occurred_at }}</view>
|
||||
</view>
|
||||
<view class="timeline-item__desc">{{ item.node_desc }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
padding: 18rpx;
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.timeline-item__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.timeline-item__title {
|
||||
color: var(--work-text);
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timeline-item__time,
|
||||
.timeline-item__desc {
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
241
work-app/src/pages/report/detail.vue
Normal file
241
work-app/src/pages/report/detail.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminReportDetail } from "../../api/admin";
|
||||
import { showErrorToast } from "../../utils/feedback";
|
||||
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const detail = ref<AdminReportDetail | null>(null);
|
||||
const reportId = ref(0);
|
||||
|
||||
const isZhongjian = computed(() => detail.value?.report_header.service_provider === "zhongjian");
|
||||
|
||||
function previewImage(urls: string[], current: string) {
|
||||
if (!urls.length) return;
|
||||
uni.previewImage({ urls, current });
|
||||
}
|
||||
|
||||
function openAsset(item: { file_url: string; file_type?: string; thumbnail_url?: string }) {
|
||||
if (item.file_type === "image") {
|
||||
const urls = [
|
||||
...(detail.value?.evidence_attachments || []).map((asset) => asset.file_url),
|
||||
...(detail.value?.zhongjian_report_files || []).map((asset) => asset.file_url),
|
||||
];
|
||||
previewImage(urls, item.file_url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.file_type === "video") {
|
||||
const previewMedia = (uni as any).previewMedia;
|
||||
if (typeof previewMedia === "function") {
|
||||
previewMedia({
|
||||
sources: [{ url: item.file_url, type: "video", poster: item.thumbnail_url || "" }],
|
||||
current: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.file_type === "pdf") {
|
||||
uni.downloadFile({
|
||||
url: item.file_url,
|
||||
success: (response) => {
|
||||
if (response.statusCode !== 200 || !response.tempFilePath) {
|
||||
uni.showToast({ title: "附件打开失败", icon: "none" });
|
||||
return;
|
||||
}
|
||||
uni.openDocument({
|
||||
filePath: response.tempFilePath,
|
||||
fileType: "pdf",
|
||||
showMenu: true,
|
||||
});
|
||||
},
|
||||
fail: () => uni.showToast({ title: "附件打开失败", icon: "none" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({ title: "当前附件暂不支持打开", icon: "none" });
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!reportId.value) return;
|
||||
loading.value = true;
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
detail.value = await adminApi.getReportDetail(reportId.value);
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "报告详情加载失败,请稍后重试。";
|
||||
}
|
||||
showErrorToast(error, "报告详情加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
reportId.value = Number(options?.id || 0);
|
||||
if (!reportId.value) {
|
||||
loadError.value = "缺少报告编号,无法查看详情。";
|
||||
}
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
if (reportId.value) {
|
||||
void fetchDetail();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="!pageReady && loading" class="empty">正在加载报告详情</view>
|
||||
<view v-else-if="!pageReady && loadError" class="empty">{{ loadError }}</view>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="hero">
|
||||
<view class="eyebrow">报告详情</view>
|
||||
<view class="title">{{ detail.report_header.report_title }}</view>
|
||||
<view class="subtitle">{{ detail.report_header.report_no }}</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row">
|
||||
<view>
|
||||
<view class="card-title">{{ detail.report_header.report_status_text }}</view>
|
||||
<view class="card-desc">{{ detail.report_header.institution_name }}</view>
|
||||
</view>
|
||||
<text class="tag">{{ detail.report_header.service_provider_text }}</text>
|
||||
</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">发布时间</view>
|
||||
<view class="meta-value">{{ detail.report_header.publish_time || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">录入人</view>
|
||||
<view class="meta-value">{{ detail.report_header.report_entry_admin_name || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">中检报告号</view>
|
||||
<view class="meta-value">{{ detail.report_header.zhongjian_report_no || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">验真次数</view>
|
||||
<view class="meta-value">{{ detail.verify_info.verify_count }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">商品信息</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">商品名称</view>
|
||||
<view class="meta-value">{{ detail.product_info.product_name || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">品类 / 品牌</view>
|
||||
<view class="meta-value">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">颜色 / 规格</view>
|
||||
<view class="meta-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">鉴定结果</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">结论</view>
|
||||
<view class="meta-value">{{ detail.result_info.result_text || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">结论说明</view>
|
||||
<view class="meta-value">{{ detail.result_info.result_desc || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">评级</view>
|
||||
<view class="meta-value">{{ detail.valuation_info.condition_grade || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">估值区间</view>
|
||||
<view class="meta-value">¥{{ detail.valuation_info.valuation_min || 0 }} - ¥{{ detail.valuation_info.valuation_max || 0 }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">附件</view>
|
||||
<view v-if="detail.evidence_attachments.length" class="list" style="margin-top: 18rpx">
|
||||
<view
|
||||
v-for="item in detail.evidence_attachments"
|
||||
:key="item.file_id"
|
||||
class="list-card"
|
||||
@click="openAsset(item)"
|
||||
>
|
||||
<view class="row">
|
||||
<view class="list-title">{{ item.name || item.file_id }}</view>
|
||||
<text class="tag">{{ item.file_type || "附件" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty" style="padding: 24rpx 0">暂无证据附件</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.zhongjian_report_files.length" class="card">
|
||||
<view class="card-title">中检报告文件</view>
|
||||
<view class="list" style="margin-top: 18rpx">
|
||||
<view
|
||||
v-for="item in detail.zhongjian_report_files"
|
||||
:key="item.file_id"
|
||||
class="list-card"
|
||||
@click="openAsset(item)"
|
||||
>
|
||||
<view class="row">
|
||||
<view class="list-title">{{ item.name || item.file_id }}</view>
|
||||
<text class="tag">{{ item.file_type || "附件" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">验真信息</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">验真状态</view>
|
||||
<view class="meta-value">{{ isZhongjian ? "中检报告" : detail.verify_info.verify_status || "valid" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">验真页</view>
|
||||
<view class="meta-value">{{ detail.verify_info.verify_url || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">验真二维码</view>
|
||||
<view class="meta-value">{{ detail.verify_info.verify_qrcode_url || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">报告页</view>
|
||||
<view class="meta-value">{{ detail.verify_info.report_page_url || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
}
|
||||
</style>
|
||||
372
work-app/src/pages/scan/index.vue
Normal file
372
work-app/src/pages/scan/index.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||
import {
|
||||
getAdminInfo,
|
||||
resolveWorkRole,
|
||||
roleText,
|
||||
type WorkRole,
|
||||
} from "../../utils/auth";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
type WarehouseMode = "inbound" | "outbound" | "lookup";
|
||||
|
||||
const role = ref<WorkRole>(resolveWorkRole());
|
||||
const mode = ref<WarehouseMode>("inbound");
|
||||
const scanValue = ref("");
|
||||
const internalTagNo = ref("");
|
||||
const materialQr = ref("");
|
||||
const expressCompany = ref("");
|
||||
const returnTrackingNo = ref("");
|
||||
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||
const loading = ref(false);
|
||||
const actionLoading = ref(false);
|
||||
|
||||
const isWarehouse = computed(() => role.value === "warehouse");
|
||||
const roleLabel = computed(() => roleText(role.value));
|
||||
const pageDesc = computed(() =>
|
||||
isWarehouse.value ? "扫描快递单号或内部流转挂牌,完成入库、出库和查单。" : "扫描内部流转挂牌,进入鉴定工单处理。",
|
||||
);
|
||||
const primaryPlaceholder = computed(() => {
|
||||
if (!isWarehouse.value) return "扫描内部流转码";
|
||||
return mode.value === "inbound" ? "扫描寄入运单号" : "扫描内部流转挂牌";
|
||||
});
|
||||
const canReceiveInbound = computed(() =>
|
||||
mode.value === "inbound" &&
|
||||
Boolean(context.value) &&
|
||||
context.value?.order_info.order_status === "pending_shipping" &&
|
||||
context.value?.logistics_info?.tracking_status !== "received" &&
|
||||
context.value?.transfer_flow?.current_stage !== "warehouse_received",
|
||||
);
|
||||
const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at));
|
||||
|
||||
function refreshRole() {
|
||||
role.value = resolveWorkRole(getAdminInfo());
|
||||
}
|
||||
|
||||
function chooseMode(next: WarehouseMode) {
|
||||
mode.value = next;
|
||||
scanValue.value = "";
|
||||
internalTagNo.value = "";
|
||||
materialQr.value = "";
|
||||
expressCompany.value = "";
|
||||
returnTrackingNo.value = "";
|
||||
context.value = null;
|
||||
}
|
||||
|
||||
function applyScanResult(value: string) {
|
||||
if (!value) return;
|
||||
scanValue.value = value.trim();
|
||||
void handlePrimaryAction();
|
||||
}
|
||||
|
||||
function openScanner() {
|
||||
uni.scanCode({
|
||||
scanType: ["barCode", "qrCode"],
|
||||
success: (result) => applyScanResult(String(result.result || "")),
|
||||
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePrimaryAction() {
|
||||
if (!scanValue.value.trim()) {
|
||||
showInfoToast(primaryPlaceholder.value);
|
||||
return;
|
||||
}
|
||||
if (!isWarehouse.value) {
|
||||
await scanAppraisalTask();
|
||||
return;
|
||||
}
|
||||
if (mode.value === "inbound") {
|
||||
await lookupInbound();
|
||||
return;
|
||||
}
|
||||
if (mode.value === "outbound") {
|
||||
await lookupOutbound();
|
||||
return;
|
||||
}
|
||||
await lookupAnyOrder();
|
||||
}
|
||||
|
||||
async function lookupInbound() {
|
||||
loading.value = true;
|
||||
try {
|
||||
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
|
||||
showInfoToast("已匹配订单");
|
||||
} catch (error) {
|
||||
context.value = null;
|
||||
showErrorToast(error, "入库查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function receiveInbound() {
|
||||
if (!scanValue.value.trim() || !internalTagNo.value.trim()) {
|
||||
showInfoToast("请填写寄入运单号和内部流转挂牌");
|
||||
return;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
context.value = await withLoading("正在入库", () =>
|
||||
adminApi.receiveWarehouseInbound({
|
||||
tracking_no: scanValue.value.trim(),
|
||||
internal_tag_no: internalTagNo.value.trim(),
|
||||
}),
|
||||
);
|
||||
showInfoToast("入库完成");
|
||||
internalTagNo.value = "";
|
||||
} catch (error) {
|
||||
showErrorToast(error, "入库失败");
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupOutbound() {
|
||||
loading.value = true;
|
||||
try {
|
||||
try {
|
||||
context.value = await adminApi.lookupZhongjianWarehouseTransfer(scanValue.value.trim());
|
||||
showInfoToast("已识别中检流转");
|
||||
return;
|
||||
} catch (zhongjianError) {
|
||||
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
|
||||
showInfoToast("已打开寄回流程");
|
||||
}
|
||||
} catch (error) {
|
||||
context.value = null;
|
||||
showErrorToast(error, "出库查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOutboundAction() {
|
||||
if (!context.value) {
|
||||
await lookupOutbound();
|
||||
return;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
if (context.value.next_action === "outbound") {
|
||||
context.value = await adminApi.zhongjianWarehouseOutbound(scanValue.value.trim());
|
||||
showInfoToast("送检出库完成");
|
||||
return;
|
||||
}
|
||||
if (context.value.next_action === "inbound") {
|
||||
context.value = await adminApi.zhongjianWarehouseInbound(scanValue.value.trim());
|
||||
showInfoToast("送检入库完成");
|
||||
return;
|
||||
}
|
||||
if (context.value.order_info.service_provider === "zhongjian") {
|
||||
context.value = await adminApi.confirmWarehouseReturnZhongjian(scanValue.value.trim());
|
||||
showInfoToast("中检报告已确认");
|
||||
return;
|
||||
}
|
||||
if (!canReturnShip.value) {
|
||||
if (!materialQr.value.trim()) {
|
||||
showInfoToast("请扫描验真吊牌");
|
||||
return;
|
||||
}
|
||||
context.value = await adminApi.verifyWarehouseReturnMaterialTag({
|
||||
internal_tag_no: scanValue.value.trim(),
|
||||
qr_input: materialQr.value.trim(),
|
||||
});
|
||||
showInfoToast("验真吊牌已确认");
|
||||
return;
|
||||
}
|
||||
if (!expressCompany.value.trim() || !returnTrackingNo.value.trim()) {
|
||||
showInfoToast("请填写回寄快递和运单号");
|
||||
return;
|
||||
}
|
||||
context.value = await adminApi.shipWarehouseReturn({
|
||||
internal_tag_no: scanValue.value.trim(),
|
||||
express_company: expressCompany.value.trim(),
|
||||
tracking_no: returnTrackingNo.value.trim(),
|
||||
});
|
||||
showInfoToast("回寄运单已登记");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "出库操作失败");
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupAnyOrder() {
|
||||
loading.value = true;
|
||||
try {
|
||||
try {
|
||||
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
|
||||
return;
|
||||
} catch {
|
||||
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
context.value = null;
|
||||
showErrorToast(error, "查单失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanAppraisalTask() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await adminApi.scanAppraisalTransferTag(scanValue.value.trim());
|
||||
showInfoToast("工单已打开");
|
||||
uni.navigateTo({ url: `/pages/task/detail?id=${data.task_id}` });
|
||||
} catch (error) {
|
||||
showErrorToast(error, "内部流转码识别失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scanInternalTagInput() {
|
||||
uni.scanCode({
|
||||
scanType: ["barCode", "qrCode"],
|
||||
success: (result) => {
|
||||
internalTagNo.value = String(result.result || "").trim();
|
||||
},
|
||||
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
|
||||
});
|
||||
}
|
||||
|
||||
function scanMaterialQr() {
|
||||
uni.scanCode({
|
||||
scanType: ["barCode", "qrCode"],
|
||||
success: (result) => {
|
||||
materialQr.value = String(result.result || "").trim();
|
||||
},
|
||||
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
|
||||
});
|
||||
}
|
||||
|
||||
onShow(refreshRole);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="hero">
|
||||
<view class="eyebrow">{{ roleLabel }}作业</view>
|
||||
<view class="title">扫码</view>
|
||||
<view class="subtitle">{{ pageDesc }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isWarehouse" class="card">
|
||||
<view class="segmented">
|
||||
<view :class="['segment', mode === 'inbound' ? 'segment--active' : '']" @click="chooseMode('inbound')">入库</view>
|
||||
<view :class="['segment', mode === 'outbound' ? 'segment--active' : '']" @click="chooseMode('outbound')">出库</view>
|
||||
<view :class="['segment', mode === 'lookup' ? 'segment--active' : '']" @click="chooseMode('lookup')">查单</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">{{ primaryPlaceholder }}</view>
|
||||
<view class="scan-control">
|
||||
<input v-model="scanValue" class="field scan-input" :placeholder="primaryPlaceholder" @confirm="handlePrimaryAction" />
|
||||
<button class="btn scan-button" @click="openScanner">扫码</button>
|
||||
</view>
|
||||
<button class="btn btn--primary main-action" :disabled="loading" @click="handlePrimaryAction">
|
||||
{{ loading ? "处理中" : isWarehouse ? "识别" : "打开工单" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="canReceiveInbound" class="card">
|
||||
<view class="card-title">入库绑定</view>
|
||||
<view class="scan-control">
|
||||
<input v-model="internalTagNo" class="field scan-input" placeholder="内部流转挂牌" />
|
||||
<button class="btn scan-button" @click="scanInternalTagInput">扫码</button>
|
||||
</view>
|
||||
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="receiveInbound">
|
||||
{{ actionLoading ? "入库中" : "确认入库" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="mode === 'outbound' && context" class="card">
|
||||
<view class="card-title">出库动作</view>
|
||||
<view class="card-desc">
|
||||
{{ context.next_action_text || (context.order_info.service_provider === 'zhongjian' ? '确认中检报告后回寄' : '确认验真吊牌后回寄') }}
|
||||
</view>
|
||||
<view v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !context.next_action" class="scan-control">
|
||||
<input v-model="materialQr" class="field scan-input" placeholder="验真吊牌二维码" />
|
||||
<button class="btn scan-button" @click="scanMaterialQr">扫码</button>
|
||||
</view>
|
||||
<view v-if="canReturnShip && !context.next_action" class="ship-fields">
|
||||
<input v-model="expressCompany" class="field" placeholder="回寄快递公司" />
|
||||
<input v-model="returnTrackingNo" class="field" placeholder="回寄运单号" />
|
||||
</view>
|
||||
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="submitOutboundAction">
|
||||
{{ actionLoading ? "提交中" : "确认操作" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="context" class="card">
|
||||
<view class="row">
|
||||
<view>
|
||||
<view class="card-title">{{ context.product_info.product_name || "待完善物品信息" }}</view>
|
||||
<view class="card-desc">{{ context.order_info.order_no }} / {{ context.order_info.appraisal_no }}</view>
|
||||
</view>
|
||||
<text class="tag">{{ context.order_info.display_status }}</text>
|
||||
</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">服务</view>
|
||||
<view class="meta-value">{{ context.order_info.service_provider_text }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">内部挂牌</view>
|
||||
<view class="meta-value">{{ context.transfer_flow?.internal_tag_no || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">流转阶段</view>
|
||||
<view class="meta-value">{{ context.transfer_flow?.current_stage_text || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">当前位置</view>
|
||||
<view class="meta-value">{{ context.transfer_flow?.current_location_text || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="context.return_address" class="return-box">
|
||||
<view class="meta-label">寄回地址</view>
|
||||
<view class="meta-value">{{ context.return_address.consignee }} / {{ context.return_address.mobile }} / {{ context.return_address.full_address }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scan-control {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.scan-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scan-button {
|
||||
width: 132rpx;
|
||||
}
|
||||
|
||||
.main-action {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.ship-fields {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.return-box {
|
||||
margin-top: 20rpx;
|
||||
padding: 18rpx;
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-warning-soft);
|
||||
}
|
||||
</style>
|
||||
705
work-app/src/pages/task/detail.vue
Normal file
705
work-app/src/pages/task/detail.vue
Normal file
@@ -0,0 +1,705 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminAppraisalTaskDetail, type AdminFileAsset } from "../../api/admin";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const supplementSubmitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const detail = ref<AdminAppraisalTaskDetail | null>(null);
|
||||
const taskId = ref(0);
|
||||
const activeSection = ref<"result" | "supplement" | "zhongjian">("result");
|
||||
|
||||
const resultText = ref("");
|
||||
const resultDesc = ref("");
|
||||
const conditionGrade = ref("");
|
||||
const conditionDesc = ref("");
|
||||
const valuationMin = ref("");
|
||||
const valuationMax = ref("");
|
||||
const valuationDesc = ref("");
|
||||
const externalRemark = ref("");
|
||||
const internalRemark = ref("");
|
||||
const zhongjianReportNo = ref("");
|
||||
const zhongjianFiles = ref<AdminFileAsset[]>([]);
|
||||
const evidenceFiles = ref<AdminFileAsset[]>([]);
|
||||
const supplementForm = reactive({
|
||||
reason: "",
|
||||
deadline: "",
|
||||
items: [{ item_name: "", guide_text: "", is_required: true }],
|
||||
});
|
||||
|
||||
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
||||
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
|
||||
const reportSummary = computed(() => detail.value?.report_summary?.report_no || "");
|
||||
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
|
||||
|
||||
function hasConditionFields(template?: AppraisalTemplate | null) {
|
||||
return (template?.condition_options?.length || 0) > 0;
|
||||
}
|
||||
|
||||
function hasValuationFields(template?: AppraisalTemplate | null) {
|
||||
return Boolean((template?.valuation_hint || "").trim());
|
||||
}
|
||||
|
||||
const showConditionFields = computed(() => hasConditionFields(detail.value?.appraisal_template));
|
||||
const showValuationFields = computed(() => hasValuationFields(detail.value?.appraisal_template));
|
||||
|
||||
function formatMoneyInput(value: string | number) {
|
||||
const num = Number(value || 0);
|
||||
return Number.isFinite(num) ? num : 0;
|
||||
}
|
||||
|
||||
function hydrate(detailData: AdminAppraisalTaskDetail) {
|
||||
detail.value = detailData;
|
||||
activeSection.value = detailData.task_info.service_provider === "zhongjian"
|
||||
? "zhongjian"
|
||||
: (detailData.supplement_task ? "supplement" : "result");
|
||||
|
||||
resultText.value = detailData.result_info.result_text || "";
|
||||
resultDesc.value = detailData.result_info.result_desc || "";
|
||||
if (hasConditionFields(detailData.appraisal_template)) {
|
||||
conditionGrade.value = detailData.result_info.condition_grade || "";
|
||||
conditionDesc.value = detailData.result_info.condition_desc || "";
|
||||
} else {
|
||||
conditionGrade.value = "";
|
||||
conditionDesc.value = "";
|
||||
}
|
||||
if (hasValuationFields(detailData.appraisal_template)) {
|
||||
valuationMin.value = detailData.result_info.valuation_min ? String(detailData.result_info.valuation_min) : "";
|
||||
valuationMax.value = detailData.result_info.valuation_max ? String(detailData.result_info.valuation_max) : "";
|
||||
valuationDesc.value = detailData.result_info.valuation_desc || "";
|
||||
} else {
|
||||
valuationMin.value = "";
|
||||
valuationMax.value = "";
|
||||
valuationDesc.value = "";
|
||||
}
|
||||
externalRemark.value = detailData.result_info.external_remark || "";
|
||||
internalRemark.value = detailData.result_info.internal_remark || "";
|
||||
zhongjianReportNo.value = detailData.zhongjian_report?.report_no || "";
|
||||
zhongjianFiles.value = [...(detailData.zhongjian_report?.files || [])];
|
||||
evidenceFiles.value = [...(detailData.result_info.attachments || [])];
|
||||
|
||||
if (detailData.supplement_task) {
|
||||
supplementForm.reason = detailData.supplement_task.reason || "";
|
||||
supplementForm.deadline = detailData.supplement_task.deadline || "";
|
||||
supplementForm.items.splice(
|
||||
0,
|
||||
supplementForm.items.length,
|
||||
...(detailData.supplement_task.items.length
|
||||
? detailData.supplement_task.items.map((item) => ({
|
||||
item_name: item.item_name,
|
||||
guide_text: item.guide_text,
|
||||
is_required: item.is_required,
|
||||
}))
|
||||
: [{ item_name: "", guide_text: "", is_required: true }]),
|
||||
);
|
||||
} else {
|
||||
supplementForm.reason = "";
|
||||
supplementForm.deadline = "";
|
||||
supplementForm.items.splice(0, supplementForm.items.length, {
|
||||
item_name: "",
|
||||
guide_text: "",
|
||||
is_required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!taskId.value) return;
|
||||
loading.value = true;
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
const data = await adminApi.getAppraisalTaskDetail(taskId.value);
|
||||
hydrate(data);
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "工单详情加载失败,请稍后重试。";
|
||||
}
|
||||
showErrorToast(error, "工单详情加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addSupplementItem() {
|
||||
supplementForm.items.push({ item_name: "", guide_text: "", is_required: true });
|
||||
}
|
||||
|
||||
function removeSupplementItem(index: number) {
|
||||
if (supplementForm.items.length === 1) {
|
||||
supplementForm.items[0].item_name = "";
|
||||
supplementForm.items[0].guide_text = "";
|
||||
supplementForm.items[0].is_required = true;
|
||||
return;
|
||||
}
|
||||
supplementForm.items.splice(index, 1);
|
||||
}
|
||||
|
||||
async function removeEvidenceFile(fileUrl: string) {
|
||||
try {
|
||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
||||
evidenceFiles.value = evidenceFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||
showInfoToast("附件已删除");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "附件删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeZhongjianFile(fileUrl: string) {
|
||||
try {
|
||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
||||
zhongjianFiles.value = zhongjianFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||
showInfoToast("文件已删除");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "文件删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function updateTemplatePoint(index: number, key: "point_value" | "point_remark", value: string) {
|
||||
const template = detail.value?.appraisal_template;
|
||||
if (!template) return;
|
||||
const current = template.key_points[index];
|
||||
if (!current) return;
|
||||
current[key] = value;
|
||||
}
|
||||
|
||||
function updateTemplatePointFromInput(
|
||||
index: number,
|
||||
key: "point_value" | "point_remark",
|
||||
event: Event,
|
||||
) {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
|
||||
updateTemplatePoint(index, key, target?.value || "");
|
||||
}
|
||||
|
||||
function templateKeyPointsPayload() {
|
||||
return detail.value?.appraisal_template?.key_points?.map((item) => ({
|
||||
point_code: item.point_code,
|
||||
point_name: item.point_name,
|
||||
point_value: item.point_value || "",
|
||||
point_remark: item.point_remark || "",
|
||||
})) || [];
|
||||
}
|
||||
|
||||
function returnToWorkOrders(message: string) {
|
||||
showInfoToast(message);
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: "/pages/work-order/index" });
|
||||
}, 700);
|
||||
}
|
||||
|
||||
async function chooseEvidenceImage() {
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
if (!result.tempFilePaths?.length) return;
|
||||
uploading.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||
evidenceFiles.value.push(asset);
|
||||
}
|
||||
showInfoToast("图片上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "图片上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseEvidenceVideo() {
|
||||
try {
|
||||
const result = await uni.chooseVideo({
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
const filePath = result.tempFilePath;
|
||||
if (!filePath) return;
|
||||
uploading.value = true;
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||
evidenceFiles.value.push(asset);
|
||||
showInfoToast("视频上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "视频上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseZhongjianImage() {
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
if (!result.tempFilePaths?.length) return;
|
||||
uploading.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||
zhongjianFiles.value.push(asset);
|
||||
}
|
||||
showInfoToast("图片上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "图片上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseZhongjianVideo() {
|
||||
try {
|
||||
const result = await uni.chooseVideo({
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
const filePath = result.tempFilePath;
|
||||
if (!filePath) return;
|
||||
uploading.value = true;
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||
zhongjianFiles.value.push(asset);
|
||||
showInfoToast("视频上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "视频上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitResult(action: "save" | "submit") {
|
||||
if (!detail.value) return;
|
||||
if (isZhongjian.value) {
|
||||
showInfoToast("中检订单请切换到中检报告区");
|
||||
activeSection.value = "zhongjian";
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "submit" && !resultText.value.trim()) {
|
||||
showInfoToast("请先填写鉴定结论");
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const conditionPayload = showConditionFields.value
|
||||
? {
|
||||
condition_grade: conditionGrade.value.trim(),
|
||||
condition_desc: conditionDesc.value.trim(),
|
||||
}
|
||||
: {
|
||||
condition_grade: "",
|
||||
condition_desc: "",
|
||||
};
|
||||
const valuationPayload = showValuationFields.value
|
||||
? {
|
||||
valuation_min: formatMoneyInput(valuationMin.value),
|
||||
valuation_max: formatMoneyInput(valuationMax.value),
|
||||
valuation_desc: valuationDesc.value.trim(),
|
||||
}
|
||||
: {
|
||||
valuation_min: 0,
|
||||
valuation_max: 0,
|
||||
valuation_desc: "",
|
||||
};
|
||||
|
||||
await withLoading(action === "submit" ? "正在提交鉴定" : "正在保存鉴定", () =>
|
||||
adminApi.saveAppraisalTaskResult({
|
||||
id: detail.value!.task_info.id,
|
||||
action,
|
||||
product_info: {
|
||||
category_id: detail.value!.product_info.category_id,
|
||||
product_name: detail.value!.product_info.product_name,
|
||||
category_name: detail.value!.product_info.category_name,
|
||||
brand_name: detail.value!.product_info.brand_name,
|
||||
color: detail.value!.product_info.color,
|
||||
size_spec: detail.value!.product_info.size_spec,
|
||||
serial_no: detail.value!.product_info.serial_no,
|
||||
},
|
||||
result_text: resultText.value.trim(),
|
||||
result_desc: resultDesc.value.trim(),
|
||||
...conditionPayload,
|
||||
...valuationPayload,
|
||||
external_remark: externalRemark.value.trim(),
|
||||
internal_remark: internalRemark.value.trim(),
|
||||
attachments: evidenceFiles.value,
|
||||
key_points: templateKeyPointsPayload(),
|
||||
}),
|
||||
);
|
||||
if (action === "submit") {
|
||||
returnToWorkOrders("鉴定已提交,正在返回工单");
|
||||
return;
|
||||
}
|
||||
showInfoToast("鉴定已保存");
|
||||
await fetchDetail();
|
||||
} catch (error) {
|
||||
showErrorToast(error, action === "submit" ? "鉴定提交失败" : "鉴定保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSupplement() {
|
||||
if (!detail.value) return;
|
||||
const items = supplementForm.items.filter((item) => item.item_name.trim());
|
||||
if (!supplementForm.reason.trim()) {
|
||||
showInfoToast("请先填写补资料原因");
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
showInfoToast("请至少填写一项补资料要求");
|
||||
return;
|
||||
}
|
||||
|
||||
supplementSubmitting.value = true;
|
||||
try {
|
||||
await adminApi.requestAppraisalTaskSupplement({
|
||||
id: detail.value.task_info.id,
|
||||
reason: supplementForm.reason.trim(),
|
||||
deadline: supplementForm.deadline.trim(),
|
||||
items: items.map((item) => ({
|
||||
item_name: item.item_name.trim(),
|
||||
guide_text: item.guide_text.trim(),
|
||||
is_required: item.is_required,
|
||||
})),
|
||||
});
|
||||
showInfoToast("已发起补资料要求");
|
||||
await fetchDetail();
|
||||
} catch (error) {
|
||||
showErrorToast(error, "发起补资料失败");
|
||||
} finally {
|
||||
supplementSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitZhongjianReport() {
|
||||
if (!detail.value) return;
|
||||
if (!zhongjianReportNo.value.trim()) {
|
||||
showInfoToast("请填写中检报告编号");
|
||||
return;
|
||||
}
|
||||
if (!zhongjianFiles.value.length) {
|
||||
showInfoToast("请至少上传 1 个中检报告文件");
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminApi.saveZhongjianAppraisalReport({
|
||||
id: detail.value.task_info.id,
|
||||
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
||||
report_files: zhongjianFiles.value,
|
||||
});
|
||||
returnToWorkOrders("中检报告已提交,正在返回工单");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "中检报告录入失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openReportDetail() {
|
||||
const reportId = Number(detail.value?.report_summary?.id || 0);
|
||||
if (!reportId) return;
|
||||
uni.navigateTo({ url: `/pages/report/detail?id=${reportId}` });
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
taskId.value = Number(options?.id || 0);
|
||||
if (!taskId.value) {
|
||||
loadError.value = "缺少工单编号,无法查看详情。";
|
||||
}
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
if (taskId.value) {
|
||||
void fetchDetail();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="!pageReady && loading" class="empty">正在加载工单详情</view>
|
||||
<view v-else-if="!pageReady && loadError" class="empty">{{ loadError }}</view>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="hero">
|
||||
<view class="eyebrow">鉴定工单</view>
|
||||
<view class="title">{{ detail.product_info.product_name || "待完善物品信息" }}</view>
|
||||
<view class="subtitle">{{ detail.task_info.order_no }} / {{ detail.task_info.appraisal_no }}</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row">
|
||||
<view>
|
||||
<view class="card-title">{{ detail.task_info.task_stage_text }} · {{ detail.task_info.status_text }}</view>
|
||||
<view class="card-desc">{{ detail.task_info.service_provider_text }} / {{ detail.task_info.assignee_name }}</view>
|
||||
</view>
|
||||
<text class="tag">{{ resultSummary }}</text>
|
||||
</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">SLA 截止</view>
|
||||
<view class="meta-value">{{ detail.task_info.sla_deadline || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">开始时间</view>
|
||||
<view class="meta-value">{{ detail.task_info.started_at || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">提交时间</view>
|
||||
<view class="meta-value">{{ detail.task_info.submitted_at || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">报告摘要</view>
|
||||
<view class="meta-value">{{ reportSummary || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="segmented">
|
||||
<view :class="['segment', activeSection === 'result' ? 'segment--active' : '']" @click="activeSection = 'result'">鉴定结论</view>
|
||||
<view :class="['segment', activeSection === 'supplement' ? 'segment--active' : '']" @click="activeSection = 'supplement'">补资料</view>
|
||||
<view :class="['segment', activeSection === 'zhongjian' ? 'segment--active' : '']" @click="activeSection = 'zhongjian'">中检报告</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
|
||||
<view class="card-title">鉴定结论</view>
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<input v-model="resultText" class="field" placeholder="结论,例如:正品 / 存疑" />
|
||||
<textarea v-model="resultDesc" class="textarea" placeholder="结论说明" />
|
||||
<template v-if="showConditionFields">
|
||||
<input v-model="conditionGrade" class="field" placeholder="成色评级" />
|
||||
<textarea v-model="conditionDesc" class="textarea" placeholder="成色说明" />
|
||||
</template>
|
||||
<template v-if="showValuationFields">
|
||||
<view class="meta-grid">
|
||||
<input v-model="valuationMin" class="field" placeholder="最低估值" />
|
||||
<input v-model="valuationMax" class="field" placeholder="最高估值" />
|
||||
</view>
|
||||
<textarea v-model="valuationDesc" class="textarea" placeholder="估值说明" />
|
||||
</template>
|
||||
<textarea v-model="externalRemark" class="textarea" placeholder="对外备注" />
|
||||
<textarea v-model="internalRemark" class="textarea" placeholder="内部备注" />
|
||||
</view>
|
||||
|
||||
<view v-if="detail.appraisal_template?.key_points?.length" class="stack" style="margin-top: 20rpx">
|
||||
<view class="card-desc">模板项</view>
|
||||
<view v-for="(item, index) in detail.appraisal_template.key_points" :key="item.point_code" class="stack">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">{{ item.point_name }}</view>
|
||||
<view class="meta-value">{{ item.point_type }}{{ item.is_required ? " · 必填" : "" }}</view>
|
||||
</view>
|
||||
<input
|
||||
:value="item.point_value"
|
||||
class="field"
|
||||
:placeholder="`${item.point_name} 值`"
|
||||
@input="updateTemplatePointFromInput(index, 'point_value', $event)"
|
||||
/>
|
||||
<textarea
|
||||
:value="item.point_remark"
|
||||
class="textarea"
|
||||
:placeholder="`${item.point_name} 说明`"
|
||||
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card-desc evidence-title">证据附件</view>
|
||||
<view v-if="evidenceFiles.length" class="list" style="margin-top: 14rpx">
|
||||
<view v-for="item in evidenceFiles" :key="item.file_url" class="list-card">
|
||||
<view class="row">
|
||||
<view class="list-title">{{ item.name || item.file_id }}</view>
|
||||
<text class="tag tag--danger" @click="removeEvidenceFile(item.file_url)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upload-actions">
|
||||
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceImage">
|
||||
<text class="action-symbol">+</text>
|
||||
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||
</button>
|
||||
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceVideo">
|
||||
<text class="action-symbol">+</text>
|
||||
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="form-actions">
|
||||
<button class="form-action form-action--secondary" :disabled="submitting" @click="submitResult('save')">保存</button>
|
||||
<button class="form-action form-action--primary" :disabled="submitting" @click="submitResult('submit')">提交</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="activeSection === 'supplement'" class="card">
|
||||
<view class="card-title">补资料</view>
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<textarea v-model="supplementForm.reason" class="textarea" placeholder="补资料原因" />
|
||||
<input v-model="supplementForm.deadline" class="field" placeholder="截止时间(可选)" />
|
||||
<view v-for="(item, index) in supplementForm.items" :key="index" class="stack">
|
||||
<input v-model="item.item_name" class="field" placeholder="补资料项名称" />
|
||||
<textarea v-model="item.guide_text" class="textarea" placeholder="补资料说明" />
|
||||
<view class="row">
|
||||
<text class="tag" :class="item.is_required ? 'tag--warning' : ''" @click="item.is_required = !item.is_required">
|
||||
{{ item.is_required ? "必传" : "选传" }}
|
||||
</text>
|
||||
<text class="tag tag--danger" @click="removeSupplementItem(index)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-actions">
|
||||
<button class="form-action form-action--secondary" @click="addSupplementItem">添加一项</button>
|
||||
<button class="form-action form-action--primary" :disabled="supplementSubmitting" @click="submitSupplement">发起补资料</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="card">
|
||||
<view class="card-title">中检报告</view>
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<input v-model="zhongjianReportNo" class="field" placeholder="中检报告编号" />
|
||||
<view v-if="zhongjianFiles.length" class="list">
|
||||
<view v-for="item in zhongjianFiles" :key="item.file_url" class="list-card">
|
||||
<view class="row">
|
||||
<view class="list-title">{{ item.name || item.file_id }}</view>
|
||||
<text class="tag tag--danger" @click="removeZhongjianFile(item.file_url)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upload-actions">
|
||||
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianImage">
|
||||
<text class="action-symbol">+</text>
|
||||
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||
</button>
|
||||
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianVideo">
|
||||
<text class="action-symbol">+</text>
|
||||
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="form-actions" :class="detail.report_summary?.id ? '' : 'form-actions--single'">
|
||||
<button class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交并发布</button>
|
||||
<button v-if="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">任务信息</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">订单号</view>
|
||||
<view class="meta-value">{{ detail.task_info.order_no }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">鉴定单号</view>
|
||||
<view class="meta-value">{{ detail.task_info.appraisal_no }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">服务类型</view>
|
||||
<view class="meta-value">{{ detail.task_info.service_provider_text }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">处理人</view>
|
||||
<view class="meta-value">{{ detail.task_info.assignee_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.evidence-title {
|
||||
margin-top: 24rpx;
|
||||
color: var(--work-text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.action-button,
|
||||
.form-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 88rpx;
|
||||
padding: 0 22rpx;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--work-radius-sm);
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-button::after,
|
||||
.form-action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.action-button[disabled],
|
||||
.form-action[disabled] {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.action-button--secondary {
|
||||
justify-content: flex-start;
|
||||
gap: 12rpx;
|
||||
border-color: var(--work-border);
|
||||
background: var(--work-card-muted);
|
||||
color: var(--work-text);
|
||||
}
|
||||
|
||||
.action-symbol {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: var(--work-accent-deep);
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.form-actions--single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-action--secondary {
|
||||
border-color: var(--work-border);
|
||||
background: #ffffff;
|
||||
color: var(--work-text);
|
||||
}
|
||||
|
||||
.form-action--primary {
|
||||
border-color: var(--work-accent);
|
||||
background: var(--work-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
225
work-app/src/pages/work-order/index.vue
Normal file
225
work-app/src/pages/work-order/index.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminAppraisalTaskListItem, type AdminOrderListItem } from "../../api/admin";
|
||||
import { getAdminInfo, resolveWorkRole, type WorkRole } from "../../utils/auth";
|
||||
import { showErrorToast } from "../../utils/feedback";
|
||||
|
||||
const role = ref<WorkRole>(resolveWorkRole());
|
||||
const keyword = ref("");
|
||||
const status = ref("");
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const total = ref(0);
|
||||
const orders = ref<AdminOrderListItem[]>([]);
|
||||
const tasks = ref<AdminAppraisalTaskListItem[]>([]);
|
||||
|
||||
const isWarehouse = computed(() => role.value === "warehouse");
|
||||
const title = computed(() => (isWarehouse.value ? "订单中心" : "鉴定工单"));
|
||||
const desc = computed(() => (isWarehouse.value ? "仅展示在途、已入仓、待寄回订单。" : "处理我的鉴定待办和历史任务。"));
|
||||
const listCount = computed(() => (isWarehouse.value ? orders.value.length : tasks.value.length));
|
||||
const hasMore = computed(() => total.value > listCount.value);
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
isWarehouse.value
|
||||
? [
|
||||
{ label: "全部", value: "warehouse_active" },
|
||||
{ label: "在途", value: "warehouse_in_transit" },
|
||||
{ label: "已入仓", value: "warehouse_received" },
|
||||
{ label: "待寄回", value: "warehouse_pending_return" },
|
||||
]
|
||||
: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "待处理", value: "pending" },
|
||||
{ label: "处理中", value: "processing" },
|
||||
{ label: "已完成", value: "completed" },
|
||||
],
|
||||
);
|
||||
|
||||
function refreshRole() {
|
||||
role.value = resolveWorkRole(getAdminInfo());
|
||||
}
|
||||
|
||||
function normalizeStatusForRole() {
|
||||
const values = statusOptions.value.map((item) => item.value);
|
||||
if (!values.includes(status.value)) {
|
||||
status.value = statusOptions.value[0]?.value || "";
|
||||
}
|
||||
}
|
||||
|
||||
function chooseStatus(value: string) {
|
||||
status.value = value;
|
||||
void fetchList(true);
|
||||
}
|
||||
|
||||
async function fetchList(reset = false) {
|
||||
if (loading.value || loadingMore.value) return;
|
||||
if (reset) {
|
||||
page.value = 1;
|
||||
total.value = 0;
|
||||
orders.value = [];
|
||||
tasks.value = [];
|
||||
}
|
||||
const isFirstPage = page.value === 1;
|
||||
if (isFirstPage) {
|
||||
loading.value = true;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isWarehouse.value) {
|
||||
const data = await adminApi.getOrders({
|
||||
keyword: keyword.value.trim(),
|
||||
status: status.value || "warehouse_active",
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
});
|
||||
orders.value = reset || isFirstPage ? data.list : orders.value.concat(data.list);
|
||||
total.value = data.total || orders.value.length;
|
||||
} else {
|
||||
const data = await adminApi.getAppraisalTasks({
|
||||
keyword: keyword.value.trim(),
|
||||
status: status.value,
|
||||
scope: "my",
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
});
|
||||
tasks.value = reset || isFirstPage ? data.list : tasks.value.concat(data.list);
|
||||
total.value = data.total || tasks.value.length;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, "工单加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
void fetchList(true);
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore.value || loading.value || loadingMore.value) return;
|
||||
page.value += 1;
|
||||
void fetchList(false);
|
||||
}
|
||||
|
||||
function openOrder(item: AdminOrderListItem) {
|
||||
uni.navigateTo({ url: `/pages/order/detail?id=${item.id}` });
|
||||
}
|
||||
|
||||
function openTask(item: AdminAppraisalTaskListItem) {
|
||||
uni.navigateTo({ url: `/pages/task/detail?id=${item.id}` });
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
refreshRole();
|
||||
normalizeStatusForRole();
|
||||
void fetchList(true);
|
||||
});
|
||||
|
||||
onReachBottom(loadMore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="hero">
|
||||
<view class="eyebrow">工单</view>
|
||||
<view class="title">{{ title }}</view>
|
||||
<view class="subtitle">{{ desc }}</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<input v-model="keyword" class="field" :placeholder="isWarehouse ? '搜索订单号 / 鉴定单号 / 商品名称' : '搜索订单号 / 外部订单号 / 商品名称'" @confirm="handleSearch" />
|
||||
<scroll-view class="status-scroll" scroll-x>
|
||||
<view class="status-row">
|
||||
<view
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:class="['status-chip', status === item.value ? 'status-chip--active' : '']"
|
||||
@click="chooseStatus(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="empty">正在加载</view>
|
||||
|
||||
<view v-else-if="isWarehouse" class="list">
|
||||
<view v-if="!orders.length" class="empty">暂无订单</view>
|
||||
<view v-for="item in orders" :key="item.id" class="list-card" @click="openOrder(item)">
|
||||
<view class="row">
|
||||
<view class="list-title">{{ item.product_name }}</view>
|
||||
<text class="tag">{{ item.warehouse_bucket_text || item.display_status }}</text>
|
||||
</view>
|
||||
<view class="list-subtitle">{{ item.order_no }} / {{ item.appraisal_no }}</view>
|
||||
<view class="list-footer">
|
||||
<text class="tag">{{ item.service_provider_text }}</text>
|
||||
<text class="list-subtitle">{{ item.created_at }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="list">
|
||||
<view v-if="!tasks.length" class="empty">暂无鉴定工单</view>
|
||||
<view v-for="item in tasks" :key="item.id" class="list-card" @click="openTask(item)">
|
||||
<view class="row">
|
||||
<view class="list-title">{{ item.product_name }}</view>
|
||||
<text :class="['tag', item.status === 'completed' ? 'tag--success' : item.status === 'returned' ? 'tag--warning' : '']">{{ item.status_text }}</text>
|
||||
</view>
|
||||
<view class="list-subtitle">{{ item.order_no }} / {{ item.external_order_no || item.appraisal_no }}</view>
|
||||
<view class="list-footer">
|
||||
<text class="tag">{{ item.service_provider_text }}</text>
|
||||
<text class="list-subtitle">{{ item.assignee_name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loadingMore" class="empty">继续加载</view>
|
||||
<view v-else-if="hasMore" class="load-more" @click="loadMore">加载更多</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-scroll {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: inline-flex;
|
||||
gap: 12rpx;
|
||||
padding-bottom: 2rpx;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
min-height: 62rpx;
|
||||
padding: 0 22rpx;
|
||||
border-radius: var(--work-radius-pill);
|
||||
background: var(--work-card-muted);
|
||||
color: var(--work-text-soft);
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
line-height: 62rpx;
|
||||
}
|
||||
|
||||
.status-chip--active {
|
||||
background: var(--work-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin-top: 22rpx;
|
||||
padding: 22rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
BIN
work-app/src/static/logo.png
Normal file
BIN
work-app/src/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
275
work-app/src/styles/app.scss
Normal file
275
work-app/src/styles/app.scss
Normal file
@@ -0,0 +1,275 @@
|
||||
page {
|
||||
min-height: 100vh;
|
||||
background: var(--work-bg);
|
||||
color: var(--work-text);
|
||||
font-family: var(--work-font);
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
picker {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 28rpx var(--work-page-x) calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 8rpx 2rpx 20rpx;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: var(--work-radius-pill);
|
||||
background: var(--work-accent-soft);
|
||||
color: var(--work-accent-deep);
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 18rpx;
|
||||
font-size: 46rpx;
|
||||
line-height: 1.18;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 12rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-top: 20rpx;
|
||||
padding: 28rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius);
|
||||
background: var(--work-card);
|
||||
box-shadow: var(--work-shadow);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 32rpx;
|
||||
line-height: 1.3;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 26rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.field {
|
||||
min-height: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: #ffffff;
|
||||
color: var(--work-text);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 150rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: #ffffff;
|
||||
color: var(--work-text);
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 88rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: #ffffff;
|
||||
color: var(--work-text);
|
||||
border: 1px solid var(--work-border);
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
border-color: var(--work-accent);
|
||||
background: var(--work-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
border-color: var(--work-danger-soft);
|
||||
background: var(--work-danger-soft);
|
||||
color: var(--work-danger);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10rpx;
|
||||
padding: 8rpx;
|
||||
border-radius: var(--work-radius);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.segment {
|
||||
min-height: 72rpx;
|
||||
border-radius: var(--work-radius-sm);
|
||||
color: var(--work-text-soft);
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 72rpx;
|
||||
}
|
||||
|
||||
.segment--active {
|
||||
background: #ffffff;
|
||||
color: var(--work-text);
|
||||
box-shadow: var(--work-shadow);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 42rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: var(--work-radius-pill);
|
||||
background: var(--work-info-soft);
|
||||
color: var(--work-info);
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag--success {
|
||||
background: var(--work-success-soft);
|
||||
color: var(--work-success);
|
||||
}
|
||||
|
||||
.tag--warning {
|
||||
background: var(--work-warning-soft);
|
||||
color: var(--work-warning);
|
||||
}
|
||||
|
||||
.tag--danger {
|
||||
background: var(--work-danger-soft);
|
||||
color: var(--work-danger);
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
min-width: 0;
|
||||
padding: 18rpx;
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--work-text-muted);
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
margin-top: 8rpx;
|
||||
color: var(--work-text);
|
||||
font-size: 26rpx;
|
||||
line-height: 1.45;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 56rpx 24rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 18rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
padding: 26rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
color: var(--work-text);
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.list-subtitle {
|
||||
margin-top: 8rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.list-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
28
work-app/src/styles/tokens.scss
Normal file
28
work-app/src/styles/tokens.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
:root,
|
||||
page {
|
||||
--work-bg: #f4f5f6;
|
||||
--work-card: #ffffff;
|
||||
--work-card-muted: #f8f9fa;
|
||||
--work-text: #202124;
|
||||
--work-text-soft: #5f6368;
|
||||
--work-text-muted: #8a8d92;
|
||||
--work-border: #e5e7eb;
|
||||
--work-border-strong: #d4d8de;
|
||||
--work-accent: #edbd00;
|
||||
--work-accent-deep: #9f7400;
|
||||
--work-accent-soft: #fff6d8;
|
||||
--work-info: #285f88;
|
||||
--work-info-soft: #edf5fb;
|
||||
--work-success: #246b50;
|
||||
--work-success-soft: #e8f5ef;
|
||||
--work-warning: #966800;
|
||||
--work-warning-soft: #fff3d6;
|
||||
--work-danger: #a53c34;
|
||||
--work-danger-soft: #fdecea;
|
||||
--work-shadow: 0 12rpx 28rpx rgba(24, 34, 48, 0.06);
|
||||
--work-radius: 16rpx;
|
||||
--work-radius-sm: 10rpx;
|
||||
--work-radius-pill: 999rpx;
|
||||
--work-page-x: 28rpx;
|
||||
--work-font: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
205
work-app/src/utils/auth.ts
Normal file
205
work-app/src/utils/auth.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
const TOKEN_KEY = "anxinyan_work_admin_token";
|
||||
const ADMIN_INFO_KEY = "anxinyan_work_admin_info";
|
||||
const SELECTED_ROLE_KEY = "anxinyan_work_current_role";
|
||||
|
||||
const TABBAR_PAGES = new Set([
|
||||
"/pages/scan/index",
|
||||
"/pages/work-order/index",
|
||||
"/pages/mine/index",
|
||||
]);
|
||||
|
||||
const PUBLIC_PAGES = new Set([
|
||||
"/pages/auth/login",
|
||||
]);
|
||||
|
||||
export const WAREHOUSE_PERMISSION = "warehouse_workbench.manage";
|
||||
export const APPRAISAL_PERMISSION = "appraisal_tasks.manage";
|
||||
export const REPORT_PERMISSION = "reports.manage";
|
||||
|
||||
export type WorkRole = "warehouse" | "appraiser";
|
||||
|
||||
export interface AdminSessionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
mobile: string;
|
||||
email: string;
|
||||
status: string;
|
||||
role_names: string[];
|
||||
permission_codes: string[];
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path) return "";
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
function splitUrl(url: string) {
|
||||
const [path, query = ""] = url.split("?");
|
||||
return { path: normalizePath(path), query };
|
||||
}
|
||||
|
||||
function buildQueryString(params: Record<string, string>) {
|
||||
return Object.entries(params)
|
||||
.filter(([, value]) => value !== "")
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
function getCurrentPageUrl() {
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1] as
|
||||
| ({ route?: string; options?: Record<string, string> } & Record<string, any>)
|
||||
| undefined;
|
||||
|
||||
if (!current?.route) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const query = buildQueryString(current.options || {});
|
||||
return `${normalizePath(current.route)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function getAdminToken() {
|
||||
return String(uni.getStorageSync(TOKEN_KEY) || "");
|
||||
}
|
||||
|
||||
export function setAdminToken(token: string) {
|
||||
uni.setStorageSync(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearAdminToken() {
|
||||
uni.removeStorageSync(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getAdminInfo(): AdminSessionInfo | null {
|
||||
const raw = String(uni.getStorageSync(ADMIN_INFO_KEY) || "");
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as AdminSessionInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAdminInfo(info: AdminSessionInfo) {
|
||||
uni.setStorageSync(ADMIN_INFO_KEY, JSON.stringify(info));
|
||||
}
|
||||
|
||||
export function clearAdminInfo() {
|
||||
uni.removeStorageSync(ADMIN_INFO_KEY);
|
||||
}
|
||||
|
||||
export function clearAdminSession() {
|
||||
clearAdminToken();
|
||||
clearAdminInfo();
|
||||
clearSelectedWorkRole();
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return getAdminToken() !== "";
|
||||
}
|
||||
|
||||
export function buildAuthHeaders(headers: Record<string, string> = {}) {
|
||||
const token = getAdminToken();
|
||||
if (!token) {
|
||||
return headers;
|
||||
}
|
||||
return {
|
||||
...headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPermission(code: string, info = getAdminInfo()) {
|
||||
return Boolean(info?.permission_codes?.includes(code));
|
||||
}
|
||||
|
||||
export function availableWorkRoles(info = getAdminInfo()): WorkRole[] {
|
||||
const roles: WorkRole[] = [];
|
||||
if (hasPermission(WAREHOUSE_PERMISSION, info)) {
|
||||
roles.push("warehouse");
|
||||
}
|
||||
if (hasPermission(APPRAISAL_PERMISSION, info)) {
|
||||
roles.push("appraiser");
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
export function hasMultipleWorkRoles(info = getAdminInfo()) {
|
||||
return availableWorkRoles(info).length > 1;
|
||||
}
|
||||
|
||||
export function getSelectedWorkRole(): WorkRole | "" {
|
||||
const role = String(uni.getStorageSync(SELECTED_ROLE_KEY) || "");
|
||||
return role === "warehouse" || role === "appraiser" ? role : "";
|
||||
}
|
||||
|
||||
export function setSelectedWorkRole(role: WorkRole, info = getAdminInfo()) {
|
||||
if (!availableWorkRoles(info).includes(role)) {
|
||||
return false;
|
||||
}
|
||||
uni.setStorageSync(SELECTED_ROLE_KEY, role);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearSelectedWorkRole() {
|
||||
uni.removeStorageSync(SELECTED_ROLE_KEY);
|
||||
}
|
||||
|
||||
export function resolveWorkRole(info = getAdminInfo()): WorkRole {
|
||||
const roles = availableWorkRoles(info);
|
||||
const selectedRole = getSelectedWorkRole();
|
||||
if (selectedRole && roles.includes(selectedRole)) {
|
||||
return selectedRole;
|
||||
}
|
||||
if (roles.length > 0) {
|
||||
return roles[0];
|
||||
}
|
||||
return "appraiser";
|
||||
}
|
||||
|
||||
export function hasAnyWorkPermission(info = getAdminInfo()) {
|
||||
return availableWorkRoles(info).length > 0;
|
||||
}
|
||||
|
||||
export function roleText(role = resolveWorkRole()) {
|
||||
return role === "warehouse" ? "仓管" : "鉴定师";
|
||||
}
|
||||
|
||||
export function isPublicPage(urlOrPath: string) {
|
||||
const { path } = splitUrl(urlOrPath);
|
||||
return PUBLIC_PAGES.has(path);
|
||||
}
|
||||
|
||||
export function redirectToLogin(targetUrl?: string) {
|
||||
const currentUrl = targetUrl || getCurrentPageUrl();
|
||||
if (isPublicPage(currentUrl || "/pages/auth/login")) {
|
||||
return;
|
||||
}
|
||||
|
||||
uni.reLaunch({
|
||||
url: `/pages/auth/login${currentUrl ? `?redirect=${encodeURIComponent(currentUrl)}` : ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureAuthenticatedPageAccess() {
|
||||
const currentUrl = getCurrentPageUrl();
|
||||
if (!currentUrl || isPublicPage(currentUrl) || isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
redirectToLogin(currentUrl);
|
||||
}
|
||||
|
||||
export function navigateAfterLogin(defaultUrl = "/pages/scan/index") {
|
||||
const { path } = splitUrl(defaultUrl);
|
||||
if (TABBAR_PAGES.has(path)) {
|
||||
uni.switchTab({ url: path });
|
||||
return;
|
||||
}
|
||||
uni.reLaunch({ url: defaultUrl });
|
||||
}
|
||||
|
||||
export function logoutAndRedirect() {
|
||||
clearAdminSession();
|
||||
uni.reLaunch({ url: "/pages/auth/login" });
|
||||
}
|
||||
10
work-app/src/utils/env.ts
Normal file
10
work-app/src/utils/env.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const LOCAL_API_BASE_URL = "http://127.0.0.1:8787";
|
||||
const PRODUCTION_API_BASE_URL = "https://api.anxinjianyan.com";
|
||||
|
||||
export function resolveApiBaseUrl() {
|
||||
if (import.meta.env.DEV) {
|
||||
return import.meta.env.VITE_API_BASE_URL || LOCAL_API_BASE_URL;
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_API_BASE_URL || PRODUCTION_API_BASE_URL;
|
||||
}
|
||||
30
work-app/src/utils/feedback.ts
Normal file
30
work-app/src/utils/feedback.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export function resolveErrorMessage(error: unknown, fallback = "操作失败,请稍后重试") {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function withLoading<T>(title: string, task: () => Promise<T>) {
|
||||
uni.showLoading({ title, mask: true });
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
export function showErrorToast(error: unknown, fallback?: string) {
|
||||
const message = resolveErrorMessage(error, fallback);
|
||||
if (message.includes("未登录或登录已过期")) {
|
||||
return;
|
||||
}
|
||||
uni.showToast({ title: message, icon: "none", duration: 2200 });
|
||||
}
|
||||
|
||||
export function showInfoToast(message: string) {
|
||||
uni.showToast({ title: message, icon: "none", duration: 1700 });
|
||||
}
|
||||
109
work-app/src/utils/request.ts
Normal file
109
work-app/src/utils/request.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { buildAuthHeaders, clearAdminSession, redirectToLogin } from "./auth";
|
||||
import { resolveApiBaseUrl } from "./env";
|
||||
|
||||
const BASE_URL = resolveApiBaseUrl().replace(/\/$/, "");
|
||||
|
||||
type RequestMethod = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "HEAD";
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
function buildUrl(url: string, params?: Record<string, string | number | undefined | null>) {
|
||||
if (!params) {
|
||||
return `${BASE_URL}${url}`;
|
||||
}
|
||||
const search = Object.entries(params)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== "")
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join("&");
|
||||
return search ? `${BASE_URL}${url}?${search}` : `${BASE_URL}${url}`;
|
||||
}
|
||||
|
||||
export function request<T>(
|
||||
url: string,
|
||||
options: {
|
||||
method?: RequestMethod;
|
||||
params?: Record<string, string | number | undefined | null>;
|
||||
data?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const method = options.method || "GET";
|
||||
const headers = buildAuthHeaders({ Accept: "application/json" });
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
uni.request({
|
||||
url: buildUrl(url, options.params),
|
||||
method,
|
||||
data: options.data,
|
||||
header: headers,
|
||||
success: (response: UniApp.RequestSuccessCallbackResult) => {
|
||||
const payload = response.data as ApiResponse<T>;
|
||||
if (payload?.code === 0) {
|
||||
resolve(payload.data);
|
||||
return;
|
||||
}
|
||||
if (payload?.code === 401) {
|
||||
clearAdminSession();
|
||||
if (!url.startsWith("/api/admin/auth/")) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}
|
||||
reject(new Error(payload?.message || "请求失败"));
|
||||
},
|
||||
fail: (error: UniApp.GeneralCallbackResult) => reject(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseUploadResponse<T>(
|
||||
response: UniApp.UploadFileSuccessCallbackResult,
|
||||
fallback = "上传失败",
|
||||
) {
|
||||
let payload: ApiResponse<T> | undefined;
|
||||
try {
|
||||
payload = JSON.parse(response.data) as ApiResponse<T>;
|
||||
} catch {
|
||||
if (response.statusCode >= 500) {
|
||||
throw new Error("服务器上传处理异常,请稍后重试");
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
|
||||
if (payload?.code === 0) {
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
if (payload?.code === 401) {
|
||||
clearAdminSession();
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
throw new Error(payload?.message || fallback);
|
||||
}
|
||||
|
||||
export function uploadFile<T>(url: string, filePath: string, formData: Record<string, string | number> = {}) {
|
||||
const baseUrl = resolveApiBaseUrl().replace(/\/$/, "");
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${baseUrl}${url}`,
|
||||
filePath,
|
||||
name: "file",
|
||||
header: buildAuthHeaders(),
|
||||
formData,
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(parseUploadResponse<T>(response));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user