first
This commit is contained in:
3
user-app/.env.development
Normal file
3
user-app/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8787
|
||||
VITE_APP_ENV=development
|
||||
VITE_APP_TITLE=安心验
|
||||
3
user-app/.env.example
Normal file
3
user-app/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8787
|
||||
VITE_APP_ENV=development
|
||||
VITE_APP_TITLE=安心验
|
||||
3
user-app/.env.production
Normal file
3
user-app/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=https://api.anxinjianyan.com
|
||||
VITE_APP_ENV=production
|
||||
VITE_APP_TITLE=安心验
|
||||
3
user-app/.env.test
Normal file
3
user-app/.env.test
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=https://test-api.example.com
|
||||
VITE_APP_ENV=test
|
||||
VITE_APP_TITLE=安心验
|
||||
21
user-app/.gitignore
vendored
Normal file
21
user-app/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
20
user-app/index.html
Normal file
20
user-app/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||
CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
8959
user-app/package-lock.json
generated
Normal file
8959
user-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
74
user-app/package.json
Normal file
74
user-app/package.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "uni-preset-vue",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev:custom": "uni -p",
|
||||
"dev:h5": "uni",
|
||||
"dev:h5:ssr": "uni --ssr",
|
||||
"dev:mp-alipay": "uni -p mp-alipay",
|
||||
"dev:mp-baidu": "uni -p mp-baidu",
|
||||
"dev:mp-jd": "uni -p mp-jd",
|
||||
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||
"dev:mp-lark": "uni -p mp-lark",
|
||||
"dev:mp-qq": "uni -p mp-qq",
|
||||
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||
"dev:mp-harmony": "uni -p mp-harmony",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"dev:mp-xhs": "uni -p mp-xhs",
|
||||
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||
"build:custom": "uni build -p",
|
||||
"build:h5": "uni build",
|
||||
"build:h5:ssr": "uni build --ssr",
|
||||
"build:mp-alipay": "uni build -p mp-alipay",
|
||||
"build:mp-baidu": "uni build -p mp-baidu",
|
||||
"build:mp-jd": "uni build -p mp-jd",
|
||||
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||
"build:mp-lark": "uni build -p mp-lark",
|
||||
"build:mp-qq": "uni build -p mp-qq",
|
||||
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||
"build:mp-harmony": "uni build -p mp-harmony",
|
||||
"sync:mp-config": "php ../server-api/tools/sync_client_configs.php",
|
||||
"build:mp-weixin": "npm run sync:mp-config && uni build -p mp-weixin",
|
||||
"build:mp-xhs": "uni build -p mp-xhs",
|
||||
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-app-harmony": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-components": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-alipay": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-baidu": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-harmony": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-jd": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-kuaishou": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-lark": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-qq": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-toutiao": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4080420251103001",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4080420251103001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4080420251103001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4080420251103001",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"sass": "^1.99.0",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "5.2.8",
|
||||
"vue-tsc": "^1.0.24"
|
||||
}
|
||||
}
|
||||
10
user-app/shims-uni.d.ts
vendored
Normal file
10
user-app/shims-uni.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types='@dcloudio/types' />
|
||||
import 'vue'
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
type Hooks = App.AppInstance & Page.PageInstance;
|
||||
|
||||
interface ComponentCustomOptions extends Hooks {
|
||||
|
||||
}
|
||||
}
|
||||
17
user-app/src/App.vue
Normal file
17
user-app/src/App.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import { ensureAuthenticatedPageAccess } from "./utils/auth";
|
||||
onLaunch(() => {
|
||||
ensureAuthenticatedPageAccess();
|
||||
});
|
||||
onShow(() => {
|
||||
ensureAuthenticatedPageAccess();
|
||||
});
|
||||
onHide(() => {
|
||||
// noop
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@use "./styles/tokens.scss";
|
||||
@use "./styles/app.scss";
|
||||
</style>
|
||||
820
user-app/src/api/app.ts
Normal file
820
user-app/src/api/app.ts
Normal file
@@ -0,0 +1,820 @@
|
||||
import { parseUploadResponse, request } from "../utils/request";
|
||||
import { buildAuthHeaders } from "../utils/auth";
|
||||
import { resolveApiBaseUrl } from "../utils/env";
|
||||
|
||||
export interface PageVisualsData {
|
||||
order_background_image_url?: string;
|
||||
report_background_image_url?: string;
|
||||
}
|
||||
|
||||
export interface HomeData {
|
||||
banners: Array<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
background_image_url?: string;
|
||||
}>;
|
||||
page_visuals: PageVisualsData;
|
||||
service_entries: Array<{
|
||||
service_provider: string;
|
||||
title: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
meta: string;
|
||||
}>;
|
||||
category_entries: Array<{
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
category_code: string;
|
||||
image_url?: string;
|
||||
}>;
|
||||
trust_points: Array<{
|
||||
title: string;
|
||||
desc: string;
|
||||
}>;
|
||||
quick_entries: Array<{
|
||||
code: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}>;
|
||||
trust_metrics: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
faqs: string[];
|
||||
}
|
||||
|
||||
export interface HelpCategoryItem {
|
||||
code: "all" | "service" | "report" | "shipping" | "support";
|
||||
title: string;
|
||||
desc: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HelpArticleSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
category: "service" | "report" | "shipping" | "support";
|
||||
category_text: string;
|
||||
summary: string;
|
||||
keywords: string[];
|
||||
updated_at: string;
|
||||
is_recommended: boolean;
|
||||
}
|
||||
|
||||
export interface HelpCenterData {
|
||||
categories: HelpCategoryItem[];
|
||||
articles: HelpArticleSummary[];
|
||||
}
|
||||
|
||||
export interface TicketTypeOption {
|
||||
code: string;
|
||||
title: string;
|
||||
hint: string;
|
||||
quick_desc: string;
|
||||
}
|
||||
|
||||
export interface TicketStatusOption {
|
||||
code: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface MessagePageCopy {
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface HelpArticleDetailData {
|
||||
article: HelpArticleSummary & {
|
||||
content_blocks: string[];
|
||||
};
|
||||
related_articles: HelpArticleSummary[];
|
||||
}
|
||||
|
||||
export interface SettingsData {
|
||||
profile_info: {
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
mobile: string;
|
||||
avatar: string;
|
||||
status: string;
|
||||
status_text: string;
|
||||
password_set: boolean;
|
||||
};
|
||||
preferences: {
|
||||
notify_order: boolean;
|
||||
notify_report: boolean;
|
||||
notify_supplement: boolean;
|
||||
notify_ticket: boolean;
|
||||
marketing_notify: boolean;
|
||||
privacy_mode: boolean;
|
||||
};
|
||||
legal_entries: Array<{
|
||||
code: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
target_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MineOverviewData {
|
||||
profile_info: SettingsData["profile_info"];
|
||||
asset_summary: {
|
||||
total_valuation: number;
|
||||
item_count: number;
|
||||
report_count: number;
|
||||
authentic_rate: number;
|
||||
unread_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrderListItem {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
order_status: string;
|
||||
product_name: string;
|
||||
product_cover: string;
|
||||
service_provider: string;
|
||||
display_status: string;
|
||||
status_desc: string;
|
||||
estimated_finish_time: string;
|
||||
primary_action: string;
|
||||
}
|
||||
|
||||
export interface OrderDetailData {
|
||||
order_info: {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
service_provider: string;
|
||||
order_status: string;
|
||||
display_status: string;
|
||||
status_desc: string;
|
||||
estimated_finish_time: string;
|
||||
can_edit_return_address: boolean;
|
||||
};
|
||||
product_info: {
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
color: string;
|
||||
size_spec: string;
|
||||
serial_no: string;
|
||||
};
|
||||
extra_info: {
|
||||
purchase_channel: string;
|
||||
purchase_price: number;
|
||||
purchase_date: string;
|
||||
usage_status: string;
|
||||
usage_status_text: string;
|
||||
condition_desc: string;
|
||||
has_accessories: boolean;
|
||||
accessories: string[];
|
||||
remark: string;
|
||||
};
|
||||
materials: Array<{
|
||||
upload_item_id: number;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
is_required: boolean;
|
||||
source_type: string;
|
||||
source_type_text: string;
|
||||
status: string;
|
||||
status_text: string;
|
||||
file_count: number;
|
||||
files: Array<{
|
||||
file_id: string;
|
||||
file_url: string;
|
||||
thumbnail_url: string;
|
||||
quality_status: string;
|
||||
quality_message: string;
|
||||
}>;
|
||||
}>;
|
||||
return_address: null | {
|
||||
user_address_id: number;
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail_address: string;
|
||||
full_address: string;
|
||||
};
|
||||
return_logistics: null | {
|
||||
express_company: string;
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
tracking_status_text: string;
|
||||
latest_desc: string;
|
||||
latest_time: string;
|
||||
nodes: Array<{
|
||||
node_time: string;
|
||||
node_desc: string;
|
||||
node_location: string;
|
||||
}>;
|
||||
};
|
||||
timeline: Array<{
|
||||
node_code: string;
|
||||
node_text: string;
|
||||
node_desc: string;
|
||||
occurred_at: string;
|
||||
}>;
|
||||
supplement_task: null | {
|
||||
task_id: number;
|
||||
reason: string;
|
||||
deadline: string;
|
||||
items: Array<{
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
guide_text: string;
|
||||
}>;
|
||||
};
|
||||
available_actions: {
|
||||
primary_action: string;
|
||||
secondary_action: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShippingDetailData {
|
||||
order_info: {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
service_provider: string;
|
||||
display_status: string;
|
||||
estimated_finish_time: string;
|
||||
product_name: string;
|
||||
};
|
||||
shipping_address: {
|
||||
warehouse_id?: number;
|
||||
warehouse_name?: string;
|
||||
warehouse_code?: string;
|
||||
receiver_name: string;
|
||||
receiver_mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail_address: string;
|
||||
service_time: string;
|
||||
notice: string;
|
||||
};
|
||||
shipping_options: {
|
||||
current_warehouse_id: number;
|
||||
can_select_warehouse: boolean;
|
||||
list: Array<{
|
||||
id: number;
|
||||
warehouse_name: string;
|
||||
warehouse_code: string;
|
||||
warehouse_type: string;
|
||||
warehouse_type_text: string;
|
||||
service_provider: string;
|
||||
service_provider_text: string;
|
||||
receiver_name: string;
|
||||
receiver_mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail_address: string;
|
||||
full_address: string;
|
||||
service_time: string;
|
||||
notice: string;
|
||||
supported_category_ids: number[];
|
||||
supported_category_names: string[];
|
||||
status: string;
|
||||
status_text: string;
|
||||
is_default: boolean;
|
||||
is_recommended?: boolean;
|
||||
recommended_reason?: string;
|
||||
sort_order: number;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
};
|
||||
shipping_notice: {
|
||||
tips: string[];
|
||||
express_recommendations: string[];
|
||||
};
|
||||
logistics_info: {
|
||||
express_company: string;
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
tracking_status_text: string;
|
||||
latest_desc: string;
|
||||
latest_time: string;
|
||||
is_submitted: boolean;
|
||||
};
|
||||
logistics_nodes: Array<{
|
||||
node_time: string;
|
||||
node_desc: string;
|
||||
node_location: string;
|
||||
}>;
|
||||
can_submit_tracking: boolean;
|
||||
}
|
||||
|
||||
export interface UserAddressItem {
|
||||
id: number;
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail_address: string;
|
||||
full_address: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReportListItem {
|
||||
report_id: number | null;
|
||||
order_id: number;
|
||||
report_no: string;
|
||||
product_name: string;
|
||||
product_cover: string;
|
||||
service_provider: string;
|
||||
status: string;
|
||||
result_text: string;
|
||||
institution_name: string;
|
||||
publish_time: string;
|
||||
}
|
||||
|
||||
export interface ReportDetailData {
|
||||
evidence_attachments: EvidenceAttachmentAsset[];
|
||||
report_header: {
|
||||
report_id: number;
|
||||
report_no: string;
|
||||
report_type: string;
|
||||
report_title: string;
|
||||
report_status: string;
|
||||
service_provider: string;
|
||||
institution_name: string;
|
||||
publish_time: string;
|
||||
};
|
||||
result_info: Record<string, any>;
|
||||
product_info: Record<string, any>;
|
||||
appraisal_info: Record<string, any>;
|
||||
valuation_info: Record<string, any>;
|
||||
risk_notice_text: string;
|
||||
verify_info: {
|
||||
report_no: string;
|
||||
verify_status: string;
|
||||
verify_url: string;
|
||||
verify_qrcode_url: string;
|
||||
};
|
||||
file_info: {
|
||||
pdf_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VerifyData {
|
||||
verify_status: string;
|
||||
verify_message: string;
|
||||
evidence_attachments: EvidenceAttachmentAsset[];
|
||||
report_summary: {
|
||||
report_no: string;
|
||||
report_title?: string;
|
||||
institution_name: string;
|
||||
publish_time: string;
|
||||
};
|
||||
product_summary: Record<string, any>;
|
||||
result_summary: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MaterialTagData {
|
||||
tag_status: "unbound" | "pending_report" | "published" | "not_found";
|
||||
status_text: string;
|
||||
message: string;
|
||||
qr_token: string;
|
||||
qr_url: string;
|
||||
scan_count: number;
|
||||
verify_count: number;
|
||||
report_summary: null | {
|
||||
report_id?: number;
|
||||
report_no: string;
|
||||
report_title: string;
|
||||
institution_name: string;
|
||||
publish_time: string;
|
||||
};
|
||||
product_summary: Record<string, any>;
|
||||
result_summary: Record<string, any>;
|
||||
verify_passed: boolean;
|
||||
}
|
||||
|
||||
export interface MaterialTagVerifyResult {
|
||||
verify_passed: boolean;
|
||||
verify_message: string;
|
||||
verify_count: number;
|
||||
}
|
||||
|
||||
export interface MessageSummaryData {
|
||||
total_count: number;
|
||||
unread_count: number;
|
||||
category_counts: {
|
||||
all: number;
|
||||
order: number;
|
||||
report: number;
|
||||
supplement: number;
|
||||
ticket: number;
|
||||
};
|
||||
latest_title: string;
|
||||
latest_time: string;
|
||||
}
|
||||
|
||||
export interface UserMessageItem {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
biz_type: string;
|
||||
biz_type_text: string;
|
||||
category: "all" | "order" | "report" | "supplement" | "ticket";
|
||||
category_text: string;
|
||||
biz_id: number;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
target_url: string;
|
||||
target_label: string;
|
||||
}
|
||||
|
||||
export interface UserMessageListData {
|
||||
list: UserMessageItem[];
|
||||
summary: {
|
||||
total_count: number;
|
||||
unread_count: number;
|
||||
category_counts: {
|
||||
all: number;
|
||||
order: number;
|
||||
report: number;
|
||||
supplement: number;
|
||||
ticket: number;
|
||||
};
|
||||
current_count: number;
|
||||
current_category: "all" | "order" | "report" | "supplement" | "ticket";
|
||||
unread_only: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SupplementFileItem {
|
||||
id: number;
|
||||
file_id: string;
|
||||
file_url: string;
|
||||
thumbnail_url: string;
|
||||
}
|
||||
|
||||
export interface SupplementDetailData {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
reason: string;
|
||||
deadline: string;
|
||||
items: Array<{
|
||||
upload_item_id: number;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
guide_text: string;
|
||||
is_required: boolean;
|
||||
status: string;
|
||||
files: SupplementFileItem[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TicketOverviewCard {
|
||||
title: string;
|
||||
value: number;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface TicketAttachmentAsset {
|
||||
file_id: string;
|
||||
file_url: string;
|
||||
thumbnail_url: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface EvidenceAttachmentAsset {
|
||||
file_id: string;
|
||||
file_url: string;
|
||||
thumbnail_url: string;
|
||||
name?: string;
|
||||
file_type: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface UserTicketListItem {
|
||||
id: number;
|
||||
ticket_no: string;
|
||||
ticket_type: string;
|
||||
ticket_type_text: string;
|
||||
status: string;
|
||||
status_text: string;
|
||||
priority: string;
|
||||
priority_text: string;
|
||||
title: string;
|
||||
order_id: number;
|
||||
latest_message: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserTicketDetailData {
|
||||
ticket_info: {
|
||||
id: number;
|
||||
ticket_no: string;
|
||||
ticket_type: string;
|
||||
ticket_type_text: string;
|
||||
status: string;
|
||||
status_text: string;
|
||||
priority: string;
|
||||
priority_text: string;
|
||||
title: string;
|
||||
content: string;
|
||||
order_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
order_info: null | {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
display_status: string;
|
||||
};
|
||||
messages: Array<{
|
||||
sender_type: string;
|
||||
sender_type_text: string;
|
||||
content: string;
|
||||
attachments: TicketAttachmentAsset[];
|
||||
created_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const appApi = {
|
||||
getHomeData() {
|
||||
return request<HomeData>("/api/app/home/index");
|
||||
},
|
||||
getPageVisuals() {
|
||||
return request<PageVisualsData>("/api/app/content/page-visuals");
|
||||
},
|
||||
getHelpCenter(params?: Record<string, string | number | undefined>) {
|
||||
return request<HelpCenterData>("/api/app/help-center", {
|
||||
params,
|
||||
});
|
||||
},
|
||||
getHelpArticleDetail(id: number) {
|
||||
return request<HelpArticleDetailData>("/api/app/help-article/detail", {
|
||||
params: { id },
|
||||
});
|
||||
},
|
||||
getSettings() {
|
||||
return request<SettingsData>("/api/app/settings");
|
||||
},
|
||||
getMineOverview() {
|
||||
return request<MineOverviewData>("/api/app/mine/overview");
|
||||
},
|
||||
saveSettings(payload: {
|
||||
nickname: string;
|
||||
preferences: SettingsData["preferences"];
|
||||
}) {
|
||||
return request<SettingsData>("/api/app/settings/save", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
getOrders() {
|
||||
return request<{ list: OrderListItem[] }>("/api/app/orders");
|
||||
},
|
||||
getOrderDetail(id: number) {
|
||||
return request<OrderDetailData>("/api/app/order/detail", {
|
||||
params: { id },
|
||||
});
|
||||
},
|
||||
getOrderShippingDetail(orderId: number) {
|
||||
return request<ShippingDetailData>("/api/app/order/shipping", {
|
||||
params: { order_id: orderId },
|
||||
});
|
||||
},
|
||||
saveOrderShipping(payload: {
|
||||
order_id: number;
|
||||
express_company: string;
|
||||
tracking_no: string;
|
||||
warehouse_id?: number;
|
||||
}) {
|
||||
return request<{
|
||||
order_id: number;
|
||||
express_company: string;
|
||||
tracking_no: string;
|
||||
}>("/api/app/order/shipping/save", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
saveOrderReturnAddress(payload: {
|
||||
order_id: number;
|
||||
address_id: number;
|
||||
}) {
|
||||
return request<{
|
||||
order_id: number;
|
||||
return_address: OrderDetailData["return_address"];
|
||||
}>("/api/app/order/return-address/save", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
getAddresses() {
|
||||
return request<{ list: UserAddressItem[] }>("/api/app/addresses");
|
||||
},
|
||||
getAddressDetail(id: number) {
|
||||
return request<UserAddressItem>("/api/app/address/detail", {
|
||||
params: { id },
|
||||
});
|
||||
},
|
||||
saveAddress(payload: {
|
||||
id?: number;
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail_address: string;
|
||||
is_default: boolean;
|
||||
}) {
|
||||
return request<{ id: number; address: UserAddressItem }>("/api/app/address/save", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
setDefaultAddress(id: number) {
|
||||
return request<{ id: number }>("/api/app/address/default", {
|
||||
method: "POST",
|
||||
data: { id },
|
||||
});
|
||||
},
|
||||
deleteAddress(id: number) {
|
||||
return request<{ id: number }>("/api/app/address/delete", {
|
||||
method: "POST",
|
||||
data: { id },
|
||||
});
|
||||
},
|
||||
getReports() {
|
||||
return request<{ list: ReportListItem[] }>("/api/app/reports");
|
||||
},
|
||||
getReportDetail(params: { id?: number; report_no?: string }) {
|
||||
return request<ReportDetailData>("/api/app/report/detail", {
|
||||
params,
|
||||
});
|
||||
},
|
||||
verifyReport(reportNo: string) {
|
||||
return request<VerifyData>("/api/app/verify", {
|
||||
params: { report_no: reportNo },
|
||||
});
|
||||
},
|
||||
getMaterialTag(token: string) {
|
||||
return request<MaterialTagData>("/api/app/material-tag", {
|
||||
params: { token },
|
||||
});
|
||||
},
|
||||
verifyMaterialTag(payload: {
|
||||
token: string;
|
||||
report_no: string;
|
||||
verify_code: string;
|
||||
}) {
|
||||
return request<MaterialTagVerifyResult>("/api/app/material-tag/verify", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
getMessageSummary() {
|
||||
return request<MessageSummaryData>("/api/app/messages/summary");
|
||||
},
|
||||
getMessageMeta() {
|
||||
return request<{ message_page_copy: MessagePageCopy }>("/api/app/messages/meta");
|
||||
},
|
||||
getMessages(params?: Record<string, string | number | undefined>) {
|
||||
return request<UserMessageListData>("/api/app/messages", {
|
||||
params,
|
||||
});
|
||||
},
|
||||
readMessage(id: number) {
|
||||
return request<{ id: number; is_read: boolean }>("/api/app/message/read", {
|
||||
method: "POST",
|
||||
data: { id },
|
||||
});
|
||||
},
|
||||
readAllMessages() {
|
||||
return request<{ affected: number }>("/api/app/messages/read-all", {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
getSupplementDetail(orderId: number) {
|
||||
return request<SupplementDetailData>("/api/app/order/supplement", {
|
||||
params: { order_id: orderId },
|
||||
});
|
||||
},
|
||||
uploadSupplementFile(payload: {
|
||||
uploadItemId: number;
|
||||
filePath: string;
|
||||
}) {
|
||||
const baseUrl = resolveApiBaseUrl().replace(/\/$/, "");
|
||||
return new Promise<SupplementFileItem>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${baseUrl}/api/app/order/supplement/file/upload`,
|
||||
filePath: payload.filePath,
|
||||
name: "file",
|
||||
header: buildAuthHeaders(),
|
||||
formData: {
|
||||
upload_item_id: payload.uploadItemId,
|
||||
},
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(parseUploadResponse<SupplementFileItem>(response, "补资料上传失败"));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteSupplementFile(fileId: string) {
|
||||
return request<{ file_id: string }>("/api/app/order/supplement/file/delete", {
|
||||
method: "POST",
|
||||
data: {
|
||||
file_id: fileId,
|
||||
},
|
||||
});
|
||||
},
|
||||
submitSupplement(orderId: number) {
|
||||
return request<{ order_id: number; next_status: string }>("/api/app/order/supplement/submit", {
|
||||
method: "POST",
|
||||
data: {
|
||||
order_id: orderId,
|
||||
},
|
||||
});
|
||||
},
|
||||
getTicketOverview() {
|
||||
return request<{ cards: TicketOverviewCard[]; ticket_types: TicketTypeOption[] }>("/api/app/tickets/overview");
|
||||
},
|
||||
getTicketMeta() {
|
||||
return request<{ ticket_types: TicketTypeOption[]; ticket_statuses: TicketStatusOption[] }>("/api/app/ticket/meta");
|
||||
},
|
||||
getTickets(params?: Record<string, string | number | undefined>) {
|
||||
return request<{ list: UserTicketListItem[] }>("/api/app/tickets", {
|
||||
params,
|
||||
});
|
||||
},
|
||||
getTicketDetail(id: number) {
|
||||
return request<UserTicketDetailData>("/api/app/ticket/detail", {
|
||||
params: { id },
|
||||
});
|
||||
},
|
||||
createTicket(payload: {
|
||||
ticket_type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
order_id?: number;
|
||||
report_id?: number;
|
||||
attachments?: TicketAttachmentAsset[];
|
||||
}) {
|
||||
return request<{ ticket_id: number; ticket_no: string }>("/api/app/ticket/create", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
replyTicket(ticketId: number, content: string, attachments: TicketAttachmentAsset[] = []) {
|
||||
return request<{ ticket_id: number }>("/api/app/ticket/reply", {
|
||||
method: "POST",
|
||||
data: {
|
||||
ticket_id: ticketId,
|
||||
content,
|
||||
attachments,
|
||||
},
|
||||
});
|
||||
},
|
||||
uploadTicketFile(filePath: string) {
|
||||
const baseUrl = resolveApiBaseUrl().replace(/\/$/, "");
|
||||
return new Promise<TicketAttachmentAsset>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${baseUrl}/api/app/ticket/file/upload`,
|
||||
filePath,
|
||||
name: "file",
|
||||
header: buildAuthHeaders(),
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(parseUploadResponse<TicketAttachmentAsset>(response, "附件上传失败"));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteTicketFile(fileUrl: string) {
|
||||
return request<{ file_url: string }>("/api/app/ticket/file/delete", {
|
||||
method: "POST",
|
||||
data: {
|
||||
file_url: fileUrl,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
175
user-app/src/api/appraisal.ts
Normal file
175
user-app/src/api/appraisal.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { parseUploadResponse, request } from "../utils/request";
|
||||
import { buildAuthHeaders } from "../utils/auth";
|
||||
import { resolveApiBaseUrl } from "../utils/env";
|
||||
import { resolveOrderSourceChannel } from "../utils/order-source";
|
||||
|
||||
export interface CatalogOption {
|
||||
brand_id?: number;
|
||||
brand_name?: string;
|
||||
}
|
||||
|
||||
export interface CategoryOption {
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
category_code: string;
|
||||
}
|
||||
|
||||
export interface UploadItem {
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
guide_text: string;
|
||||
sample_image_url: string;
|
||||
is_required: boolean;
|
||||
quality_status: string;
|
||||
quality_message: string;
|
||||
files?: UploadFileAsset[];
|
||||
}
|
||||
|
||||
export interface UploadFileAsset {
|
||||
file_id: string;
|
||||
file_url: string;
|
||||
thumbnail_url: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DraftDetail {
|
||||
draft_id: number;
|
||||
service_provider: string;
|
||||
service_mode: string;
|
||||
current_step: number;
|
||||
product_info: Record<string, any>;
|
||||
extra_info: Record<string, any>;
|
||||
upload_info?: {
|
||||
items: UploadItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PreviewData {
|
||||
service_summary: {
|
||||
service_provider: string;
|
||||
service_provider_text: string;
|
||||
};
|
||||
product_summary: {
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
price: number;
|
||||
};
|
||||
upload_summary: {
|
||||
uploaded_count: number;
|
||||
};
|
||||
fee_detail: {
|
||||
service_fee: number;
|
||||
discount_fee: number;
|
||||
pay_amount: number;
|
||||
};
|
||||
agreements: Array<{
|
||||
code: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
target_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SubmitResult {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
pay_amount: number;
|
||||
next_status: string;
|
||||
}
|
||||
|
||||
export const appraisalApi = {
|
||||
createDraft(serviceProvider: string) {
|
||||
return request<{ draft_id: number; service_provider: string; service_mode: string }>(
|
||||
"/api/app/appraisal/draft/create",
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
service_provider: serviceProvider,
|
||||
service_mode: "physical",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
getDraft(draftId: number) {
|
||||
return request<DraftDetail>("/api/app/appraisal/draft", {
|
||||
params: { draft_id: draftId },
|
||||
});
|
||||
},
|
||||
saveDraft(payload: Record<string, unknown>) {
|
||||
return request<{ draft_id: number; current_step: number }>("/api/app/appraisal/draft/save", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
getBrands(categoryId: number) {
|
||||
return request<{ list: CatalogOption[] }>("/api/app/catalog/brands", {
|
||||
params: { category_id: categoryId },
|
||||
});
|
||||
},
|
||||
getCategories() {
|
||||
return request<{ list: CategoryOption[] }>("/api/app/catalog/categories");
|
||||
},
|
||||
getUploadTemplate(categoryId: number, serviceProvider: string) {
|
||||
return request<{ template_id: number; required_items: UploadItem[]; optional_items: UploadItem[] }>(
|
||||
"/api/app/appraisal/upload-template",
|
||||
{
|
||||
params: { category_id: categoryId, service_provider: serviceProvider },
|
||||
},
|
||||
);
|
||||
},
|
||||
preview(draftId: number) {
|
||||
return request<PreviewData>("/api/app/appraisal/preview", {
|
||||
method: "POST",
|
||||
data: { draft_id: draftId },
|
||||
});
|
||||
},
|
||||
submit(draftId: number, returnAddressId?: number) {
|
||||
return request<SubmitResult>("/api/app/appraisal/submit", {
|
||||
method: "POST",
|
||||
data: {
|
||||
draft_id: draftId,
|
||||
return_address_id: returnAddressId,
|
||||
source_channel: resolveOrderSourceChannel(),
|
||||
},
|
||||
});
|
||||
},
|
||||
uploadFile(payload: {
|
||||
draftId: number;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
filePath: string;
|
||||
}) {
|
||||
const baseUrl = resolveApiBaseUrl().replace(/\/$/, "");
|
||||
return new Promise<UploadFileAsset>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${baseUrl}/api/app/appraisal/file/upload`,
|
||||
filePath: payload.filePath,
|
||||
name: "file",
|
||||
header: buildAuthHeaders(),
|
||||
formData: {
|
||||
draft_id: payload.draftId,
|
||||
item_code: payload.itemCode,
|
||||
item_name: payload.itemName,
|
||||
},
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(parseUploadResponse<UploadFileAsset>(response, "图片上传失败"));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteFile(fileUrl: string) {
|
||||
return request<{ file_url: string }>("/api/app/appraisal/file/delete", {
|
||||
method: "POST",
|
||||
data: {
|
||||
file_url: fileUrl,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
62
user-app/src/api/auth.ts
Normal file
62
user-app/src/api/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { request } from "../utils/request";
|
||||
|
||||
export interface AuthUserInfo {
|
||||
id: number;
|
||||
nickname: string;
|
||||
mobile: string;
|
||||
avatar: string;
|
||||
status: string;
|
||||
password_set: boolean;
|
||||
}
|
||||
|
||||
export interface SendLoginCodeResult {
|
||||
mobile: string;
|
||||
scene: string;
|
||||
expire_seconds: number;
|
||||
retry_after_seconds: number;
|
||||
debug_code?: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
user_info: AuthUserInfo;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
sendLoginCode(mobile: string) {
|
||||
return request<SendLoginCodeResult>("/api/app/auth/send-code", {
|
||||
method: "POST",
|
||||
data: { mobile },
|
||||
});
|
||||
},
|
||||
loginByCode(mobile: string, code: string) {
|
||||
return request<LoginResult>("/api/app/auth/login/code", {
|
||||
method: "POST",
|
||||
data: { mobile, code },
|
||||
});
|
||||
},
|
||||
loginByPassword(mobile: string, password: string) {
|
||||
return request<LoginResult>("/api/app/auth/login/password", {
|
||||
method: "POST",
|
||||
data: { mobile, password },
|
||||
});
|
||||
},
|
||||
getMe() {
|
||||
return request<{ user_info: AuthUserInfo }>("/api/app/auth/me");
|
||||
},
|
||||
savePassword(payload: {
|
||||
current_password?: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}) {
|
||||
return request<{ user_id: number; password_set: boolean; had_password: boolean }>("/api/app/auth/password/save", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
logout() {
|
||||
return request<Record<string, never>>("/api/app/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
};
|
||||
58
user-app/src/components/FlowStepHeader.vue
Normal file
58
user-app/src/components/FlowStepHeader.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
step: number;
|
||||
total?: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
chips?: string[];
|
||||
}>(),
|
||||
{
|
||||
total: 6,
|
||||
chips: () => [],
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="section-card flow-step-header">
|
||||
<view class="tag tag--accent">步骤 {{ props.step }} / {{ props.total }}</view>
|
||||
<view class="page-title flow-step-header__title">
|
||||
{{ props.title }}
|
||||
</view>
|
||||
<view class="section__desc flow-step-header__desc">{{ props.desc }}</view>
|
||||
|
||||
<view v-if="props.chips.length" class="flow-step-header__chips">
|
||||
<text v-for="item in props.chips" :key="item" class="tag tag--neutral">
|
||||
{{ item }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="step-bar">
|
||||
<view
|
||||
v-for="item in props.total"
|
||||
:key="item"
|
||||
:class="['step-bar__item', item <= props.step ? 'step-bar__item--active' : '']"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flow-step-header__title {
|
||||
margin-top: 20rpx;
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.flow-step-header__desc {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.flow-step-header__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
</style>
|
||||
8
user-app/src/env.d.ts
vendored
Normal file
8
user-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
|
||||
}
|
||||
12
user-app/src/main.ts
Normal file
12
user-app/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createSSRApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
return {
|
||||
app,
|
||||
};
|
||||
}
|
||||
72
user-app/src/manifest.json
Normal file
72
user-app/src/manifest.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name" : "",
|
||||
"appid" : "",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
/* 模块配置 */
|
||||
"modules" : {},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
/* android打包配置 */
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"ios" : {},
|
||||
/* SDK配置 */
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx1234567890test",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
695
user-app/src/mocks/app.ts
Normal file
695
user-app/src/mocks/app.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
import type {
|
||||
HomeData,
|
||||
HelpArticleDetailData,
|
||||
HelpCenterData,
|
||||
MessageSummaryData,
|
||||
OrderDetailData,
|
||||
OrderListItem,
|
||||
ReportDetailData,
|
||||
ReportListItem,
|
||||
SettingsData,
|
||||
ShippingDetailData,
|
||||
UserAddressItem,
|
||||
TicketOverviewCard,
|
||||
UserTicketDetailData,
|
||||
UserTicketListItem,
|
||||
UserMessageListData,
|
||||
VerifyData,
|
||||
} from "../api/app";
|
||||
|
||||
export const homeFallback: HomeData = {
|
||||
banners: [
|
||||
{
|
||||
title: "安心验",
|
||||
subtitle: "独立第三方鉴定服务平台",
|
||||
description: "专业鉴定高价值商品,报告可验真,流程可追踪。",
|
||||
background_image_url: "",
|
||||
},
|
||||
],
|
||||
page_visuals: {
|
||||
order_background_image_url: "",
|
||||
report_background_image_url: "",
|
||||
},
|
||||
service_entries: [
|
||||
{
|
||||
service_provider: "anxinyan",
|
||||
title: "实物鉴定",
|
||||
tag: "标准服务",
|
||||
description: "由安心验提供标准实物鉴定服务,适合正式结果交付场景。",
|
||||
meta: "预计 48 小时内出结果 | 报告可验真",
|
||||
},
|
||||
{
|
||||
service_provider: "zhongjian",
|
||||
title: "中检鉴定",
|
||||
tag: "更高规格机构",
|
||||
description: "由更高规格机构提供实物鉴定服务,适合更高要求场景。",
|
||||
meta: "流程一致 | 出具机构不同 | 价格与时效有差异",
|
||||
},
|
||||
],
|
||||
category_entries: [
|
||||
{ category_id: 1, category_name: "奢侈品箱包", category_code: "luxury_bag" },
|
||||
{ category_id: 2, category_name: "潮流鞋类", category_code: "sneaker" },
|
||||
{ category_id: 3, category_name: "腕表", category_code: "watch" },
|
||||
{ category_id: 4, category_name: "首饰配饰", category_code: "jewelry" },
|
||||
{ category_id: 5, category_name: "3C 数码", category_code: "digital" },
|
||||
{ category_id: 6, category_name: "高端美妆", category_code: "beauty" },
|
||||
{ category_id: 7, category_name: "服饰", category_code: "clothing" },
|
||||
{ category_id: 8, category_name: "古董文玩", category_code: "antique" },
|
||||
],
|
||||
trust_points: [
|
||||
{ title: "独立第三方", desc: "保持中立判断,不参与买卖立场。" },
|
||||
{ title: "报告可验真", desc: "每份正式报告均支持编号与状态验证。" },
|
||||
{ title: "流程可追踪", desc: "从下单到出报告,关键节点一目了然。" },
|
||||
{ title: "标准化作业", desc: "按模板采集资料,单次鉴定后出具报告。" },
|
||||
],
|
||||
quick_entries: [
|
||||
{ code: "start", title: "发起鉴定", desc: "进入送检流程" },
|
||||
{ code: "orders", title: "我的订单", desc: "查看当前进度" },
|
||||
{ code: "reports", title: "我的报告", desc: "查看结果凭证" },
|
||||
{ code: "messages", title: "消息中心", desc: "查看服务提醒与结果通知" },
|
||||
],
|
||||
trust_metrics: [
|
||||
{ value: "1280+", label: "累计鉴定申请" },
|
||||
{ value: "48h", label: "标准结果时效" },
|
||||
{ value: "100%", label: "正式报告可验真" },
|
||||
],
|
||||
faqs: ["实物鉴定和中检鉴定有什么区别?", "一般多久可以出结果?", "报告如何验证真伪?"],
|
||||
};
|
||||
|
||||
export const ordersFallback: OrderListItem[] = [
|
||||
{
|
||||
order_id: 1,
|
||||
order_no: "AXY202604200001",
|
||||
appraisal_no: "AXY-APP-20260420-0001",
|
||||
order_status: "pending_supplement",
|
||||
product_name: "Louis Vuitton Neverfull MM",
|
||||
product_cover: "",
|
||||
service_provider: "zhongjian",
|
||||
display_status: "等待您补充资料",
|
||||
status_desc: "还差 2 项必传资料",
|
||||
estimated_finish_time: "2026-04-21 18:00:00",
|
||||
primary_action: "去补资料",
|
||||
},
|
||||
{
|
||||
order_id: 2,
|
||||
order_no: "AXY202604190012",
|
||||
appraisal_no: "AXY-APP-20260419-0012",
|
||||
order_status: "pending_shipping",
|
||||
product_name: "Air Jordan 1 High OG",
|
||||
product_cover: "",
|
||||
service_provider: "anxinyan",
|
||||
display_status: "鉴定师处理中",
|
||||
status_desc: "鉴定师正在处理,预计 24 小时内出具报告",
|
||||
estimated_finish_time: "2026-04-20 20:00:00",
|
||||
primary_action: "查看进度",
|
||||
},
|
||||
{
|
||||
order_id: 3,
|
||||
order_no: "AXY202604180088",
|
||||
appraisal_no: "AXY-APP-20260418-0088",
|
||||
order_status: "completed",
|
||||
product_name: "Rolex Datejust 36",
|
||||
product_cover: "",
|
||||
service_provider: "zhongjian",
|
||||
display_status: "报告已出具",
|
||||
status_desc: "正式报告可查看并验真",
|
||||
estimated_finish_time: "2026-04-18 20:00:00",
|
||||
primary_action: "查看报告",
|
||||
},
|
||||
];
|
||||
|
||||
export const orderDetailFallback: OrderDetailData = {
|
||||
order_info: {
|
||||
order_id: 1,
|
||||
order_no: "AXY202604200001",
|
||||
appraisal_no: "AXY-APP-20260420-0001",
|
||||
service_provider: "zhongjian",
|
||||
order_status: "pending_supplement",
|
||||
display_status: "等待您补充资料",
|
||||
status_desc: "鉴定师需要您补充 2 项资料后继续处理,建议尽快完成。",
|
||||
estimated_finish_time: "2026-04-21 18:00:00",
|
||||
can_edit_return_address: true,
|
||||
},
|
||||
product_info: {
|
||||
product_name: "Louis Vuitton 奢侈品箱包",
|
||||
category_name: "奢侈品箱包",
|
||||
brand_name: "Louis Vuitton",
|
||||
color: "老花",
|
||||
size_spec: "MM",
|
||||
serial_no: "AR2199",
|
||||
},
|
||||
extra_info: {
|
||||
purchase_channel: "专柜",
|
||||
purchase_price: 12600,
|
||||
purchase_date: "2026-03-16",
|
||||
usage_status: "light_use",
|
||||
usage_status_text: "轻微使用痕迹",
|
||||
condition_desc: "包身轻微折痕,五金边缘有轻微使用痕迹。",
|
||||
has_accessories: true,
|
||||
accessories: ["防尘袋", "包装盒", "发票 / 小票"],
|
||||
remark: "包内附有购买小票和防尘袋,编号位于内袋皮标附近。",
|
||||
},
|
||||
materials: [
|
||||
{
|
||||
upload_item_id: 11,
|
||||
item_code: "bag_front",
|
||||
item_name: "正面整体图",
|
||||
is_required: true,
|
||||
source_type: "initial",
|
||||
source_type_text: "下单资料",
|
||||
status: "uploaded",
|
||||
status_text: "已上传",
|
||||
file_count: 2,
|
||||
files: [
|
||||
{
|
||||
file_id: "mock-bag-front-1",
|
||||
file_url: "https://dummyimage.com/1200x1200/f3ede2/9b8358&text=Bag+Front+1",
|
||||
thumbnail_url: "https://dummyimage.com/320x320/f3ede2/9b8358&text=Front+1",
|
||||
quality_status: "uploaded",
|
||||
quality_message: "",
|
||||
},
|
||||
{
|
||||
file_id: "mock-bag-front-2",
|
||||
file_url: "https://dummyimage.com/1200x1200/efe7d9/9b8358&text=Bag+Front+2",
|
||||
thumbnail_url: "https://dummyimage.com/320x320/efe7d9/9b8358&text=Front+2",
|
||||
quality_status: "uploaded",
|
||||
quality_message: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
upload_item_id: 12,
|
||||
item_code: "serial_label",
|
||||
item_name: "编码 / 标签图",
|
||||
is_required: true,
|
||||
source_type: "supplement",
|
||||
source_type_text: "补充资料",
|
||||
status: "uploaded",
|
||||
status_text: "已上传",
|
||||
file_count: 1,
|
||||
files: [
|
||||
{
|
||||
file_id: "mock-serial-1",
|
||||
file_url: "https://dummyimage.com/1200x1200/f7f1e6/9b8358&text=Serial+Label",
|
||||
thumbnail_url: "https://dummyimage.com/320x320/f7f1e6/9b8358&text=Serial",
|
||||
quality_status: "uploaded",
|
||||
quality_message: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
return_address: {
|
||||
user_address_id: 1,
|
||||
consignee: "安心验体验用户",
|
||||
mobile: "13800000000",
|
||||
province: "广东省",
|
||||
city: "深圳市",
|
||||
district: "南山区",
|
||||
detail_address: "科技园测试路 88 号",
|
||||
full_address: "广东省深圳市南山区科技园测试路 88 号",
|
||||
},
|
||||
return_logistics: null,
|
||||
timeline: [
|
||||
{ node_code: "created", node_text: "下单成功", node_desc: "订单已生成并完成支付", occurred_at: "2026-04-20 09:12:00" },
|
||||
{ node_code: "submitted", node_text: "资料已提交", node_desc: "用户已完成首轮资料上传", occurred_at: "2026-04-20 09:30:00" },
|
||||
{ node_code: "first_review", node_text: "鉴定中", node_desc: "鉴定师正在进行专业判断", occurred_at: "2026-04-20 10:20:00" },
|
||||
{ node_code: "supplement", node_text: "待补资料", node_desc: "鉴定师需要补充编码近照与五金细节图", occurred_at: "2026-04-20 11:16:00" },
|
||||
],
|
||||
supplement_task: {
|
||||
task_id: 1,
|
||||
reason: "鉴定师需要补充编码近照与五金细节图,以继续完成判断。",
|
||||
deadline: "2026-04-21 18:00:00",
|
||||
items: [
|
||||
{ item_code: "serial_label", item_name: "编码 / 标签图", guide_text: "请补充清晰近照,确保编码内容完整可辨认。" },
|
||||
{ item_code: "hardware_detail", item_name: "五金细节图", guide_text: "请避免反光与遮挡,完整拍摄边缘与刻印细节。" },
|
||||
],
|
||||
},
|
||||
available_actions: {
|
||||
primary_action: "去补资料",
|
||||
secondary_action: "联系客服",
|
||||
},
|
||||
};
|
||||
|
||||
export const shippingDetailFallback: ShippingDetailData = {
|
||||
order_info: {
|
||||
order_id: 4,
|
||||
order_no: "AXY20260420212747131",
|
||||
appraisal_no: "AXY-APP-20260420-2326",
|
||||
service_provider: "anxinyan",
|
||||
display_status: "待寄送商品",
|
||||
estimated_finish_time: "2026-04-22 21:27:47",
|
||||
product_name: "Neverfull MM",
|
||||
},
|
||||
shipping_address: {
|
||||
warehouse_id: 1,
|
||||
warehouse_name: "安心验鉴定中心",
|
||||
warehouse_code: "AXY-WH-DEFAULT",
|
||||
receiver_name: "安心验鉴定中心",
|
||||
receiver_mobile: "400-800-1314",
|
||||
province: "广东省",
|
||||
city: "深圳市",
|
||||
district: "南山区",
|
||||
detail_address: "科技园鉴定路 88 号 安心验收件中心",
|
||||
service_time: "周一至周日 09:30-18:30",
|
||||
notice: "寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。",
|
||||
},
|
||||
shipping_options: {
|
||||
current_warehouse_id: 1,
|
||||
can_select_warehouse: true,
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
warehouse_name: "安心验鉴定中心",
|
||||
warehouse_code: "AXY-WH-DEFAULT",
|
||||
warehouse_type: "detection_center",
|
||||
warehouse_type_text: "检测中心 / 收货仓库",
|
||||
service_provider: "anxinyan",
|
||||
service_provider_text: "实物鉴定",
|
||||
receiver_name: "安心验鉴定中心",
|
||||
receiver_mobile: "400-800-1314",
|
||||
province: "广东省",
|
||||
city: "深圳市",
|
||||
district: "南山区",
|
||||
detail_address: "科技园鉴定路 88 号 安心验收件中心",
|
||||
full_address: "广东省深圳市南山区科技园鉴定路 88 号 安心验收件中心",
|
||||
service_time: "周一至周日 09:30-18:30",
|
||||
notice: "寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。",
|
||||
supported_category_ids: [],
|
||||
supported_category_names: [],
|
||||
status: "enabled",
|
||||
status_text: "启用中",
|
||||
is_default: true,
|
||||
is_recommended: true,
|
||||
recommended_reason: "默认仓库",
|
||||
sort_order: 1,
|
||||
remark: "默认仓库",
|
||||
created_at: "2026-04-20 20:44:00",
|
||||
updated_at: "2026-04-20 20:44:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
shipping_notice: {
|
||||
tips: [
|
||||
"请在包裹内附上订单号或鉴定单号,便于鉴定中心快速匹配。",
|
||||
"贵重商品建议使用顺丰、京东等可追踪快递,并保留寄件凭证。",
|
||||
"寄出后请尽快填写快递公司和运单号,我们会同步更新处理进度。",
|
||||
],
|
||||
express_recommendations: ["顺丰速运", "京东快递", "EMS", "中通快递"],
|
||||
},
|
||||
logistics_info: {
|
||||
express_company: "",
|
||||
tracking_no: "",
|
||||
tracking_status: "",
|
||||
tracking_status_text: "待提交",
|
||||
latest_desc: "",
|
||||
latest_time: "",
|
||||
is_submitted: false,
|
||||
},
|
||||
logistics_nodes: [],
|
||||
can_submit_tracking: true,
|
||||
};
|
||||
|
||||
export const addressesFallback: UserAddressItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
consignee: "安心验体验用户",
|
||||
mobile: "13800000000",
|
||||
province: "广东省",
|
||||
city: "深圳市",
|
||||
district: "南山区",
|
||||
detail_address: "科技园测试路 88 号",
|
||||
full_address: "广东省深圳市南山区科技园测试路 88 号",
|
||||
is_default: true,
|
||||
created_at: "2026-04-20 20:44:00",
|
||||
updated_at: "2026-04-20 20:44:00",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
consignee: "备用收件人",
|
||||
mobile: "13900000000",
|
||||
province: "广东省",
|
||||
city: "广州市",
|
||||
district: "天河区",
|
||||
detail_address: "员村四横路 12 号",
|
||||
full_address: "广东省广州市天河区员村四横路 12 号",
|
||||
is_default: false,
|
||||
created_at: "2026-04-20 20:44:00",
|
||||
updated_at: "2026-04-20 20:44:00",
|
||||
},
|
||||
];
|
||||
|
||||
export const reportsFallback: ReportListItem[] = [
|
||||
{
|
||||
report_id: 1,
|
||||
order_id: 3,
|
||||
report_no: "AXY-R-20260420-0001",
|
||||
product_name: "Rolex Datejust 36",
|
||||
product_cover: "",
|
||||
service_provider: "zhongjian",
|
||||
status: "已出报告",
|
||||
result_text: "正品",
|
||||
institution_name: "中检合作机构",
|
||||
publish_time: "2026-04-18 18:26:00",
|
||||
},
|
||||
{
|
||||
report_id: null,
|
||||
order_id: 2,
|
||||
report_no: "",
|
||||
product_name: "Air Jordan 1 High OG",
|
||||
product_cover: "",
|
||||
service_provider: "anxinyan",
|
||||
status: "待出报告",
|
||||
result_text: "待出报告",
|
||||
institution_name: "安心验",
|
||||
publish_time: "",
|
||||
},
|
||||
];
|
||||
|
||||
export const reportDetailFallback: ReportDetailData = {
|
||||
evidence_attachments: [],
|
||||
report_header: {
|
||||
report_id: 1,
|
||||
report_no: "AXY-R-20260420-0001",
|
||||
report_type: "appraisal",
|
||||
report_title: "中检鉴定报告",
|
||||
report_status: "published",
|
||||
service_provider: "zhongjian",
|
||||
institution_name: "中检合作机构",
|
||||
publish_time: "2026-04-18 18:26:00",
|
||||
},
|
||||
result_info: {
|
||||
result_status: "authentic",
|
||||
result_text: "正品",
|
||||
result_desc: "综合当前送检资料与商品特征判断,符合正品特征。",
|
||||
},
|
||||
product_info: {
|
||||
product_name: "Rolex 腕表",
|
||||
category_name: "腕表",
|
||||
brand_name: "Rolex",
|
||||
color: "银盘",
|
||||
size_spec: "36mm",
|
||||
},
|
||||
appraisal_info: {
|
||||
service_provider: "zhongjian",
|
||||
institution_name: "中检合作机构",
|
||||
appraiser_name: "张师傅",
|
||||
reviewer_name: "李师傅",
|
||||
appraisal_time: "2026-04-18 16:00:00",
|
||||
},
|
||||
valuation_info: {
|
||||
condition_grade: "A",
|
||||
condition_desc: "整体状态良好,存在轻微使用痕迹。",
|
||||
valuation_min: 2800,
|
||||
valuation_max: 3200,
|
||||
valuation_desc: "当前估值仅供参考,具体以市场流通情况为准。",
|
||||
},
|
||||
risk_notice_text: "本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。",
|
||||
verify_info: {
|
||||
report_no: "AXY-R-20260420-0001",
|
||||
verify_status: "valid",
|
||||
verify_url: "/#/pages/verify/result?report_no=AXY-R-20260420-0001",
|
||||
verify_qrcode_url: "/#/pages/report/detail?report_no=AXY-R-20260420-0001",
|
||||
},
|
||||
file_info: {
|
||||
pdf_url: "http://127.0.0.1:8787/uploads/reports/20260418/AXY-R-20260420-0001.pdf",
|
||||
},
|
||||
};
|
||||
|
||||
export const verifyFallback: VerifyData = {
|
||||
verify_status: "valid",
|
||||
verify_message: "该报告真实有效,可作为对应鉴定结果参考。",
|
||||
evidence_attachments: [],
|
||||
report_summary: {
|
||||
report_no: "AXY-R-20260420-0001",
|
||||
institution_name: "中检合作机构",
|
||||
publish_time: "2026-04-18 18:26:00",
|
||||
},
|
||||
product_summary: {
|
||||
product_name: "Rolex 腕表",
|
||||
category_name: "腕表",
|
||||
brand_name: "Rolex",
|
||||
},
|
||||
result_summary: {
|
||||
result_text: "正品",
|
||||
},
|
||||
};
|
||||
|
||||
export const messageSummaryFallback: MessageSummaryData = {
|
||||
total_count: 3,
|
||||
unread_count: 2,
|
||||
category_counts: {
|
||||
all: 3,
|
||||
order: 2,
|
||||
report: 1,
|
||||
supplement: 0,
|
||||
ticket: 0,
|
||||
},
|
||||
latest_title: "报告已出具",
|
||||
latest_time: "2026-04-20 20:55:17",
|
||||
};
|
||||
|
||||
export const helpCenterFallback: HelpCenterData = {
|
||||
categories: [
|
||||
{ code: "all", title: "全部", desc: "查看全部帮助文章", count: 6 },
|
||||
{ code: "service", title: "服务流程", desc: "了解下单、寄送、鉴定流程", count: 2 },
|
||||
{ code: "report", title: "报告验真", desc: "了解报告查看、下载与验真", count: 1 },
|
||||
{ code: "shipping", title: "寄送物流", desc: "了解寄送、运单和签收说明", count: 1 },
|
||||
{ code: "support", title: "售后支持", desc: "了解补资料、工单和客服协助", count: 2 },
|
||||
],
|
||||
articles: [
|
||||
{
|
||||
id: 1,
|
||||
title: "实物鉴定和中检鉴定有什么区别?",
|
||||
category: "service",
|
||||
category_text: "服务流程",
|
||||
summary: "两种服务的核心流程一致,差异主要体现在出具机构、时效与价格上。",
|
||||
keywords: ["实物鉴定", "中检鉴定", "服务区别"],
|
||||
updated_at: "2026-04-21 09:00:00",
|
||||
is_recommended: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "一般多久可以出结果?",
|
||||
category: "service",
|
||||
category_text: "服务流程",
|
||||
summary: "标准版通常 48 小时左右,具体取决于服务类型、资料完整度和物流节点。",
|
||||
keywords: ["时效", "出结果", "多久"],
|
||||
updated_at: "2026-04-21 09:00:00",
|
||||
is_recommended: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "报告如何验证真伪?",
|
||||
category: "report",
|
||||
category_text: "报告验真",
|
||||
summary: "正式报告出具后,可通过报告详情页或验真页输入编号进行验证。",
|
||||
keywords: ["报告", "验真", "验证真伪"],
|
||||
updated_at: "2026-04-21 09:00:00",
|
||||
is_recommended: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const helpArticleDetailFallback: HelpArticleDetailData = {
|
||||
article: {
|
||||
id: 1,
|
||||
title: "实物鉴定和中检鉴定有什么区别?",
|
||||
category: "service",
|
||||
category_text: "服务流程",
|
||||
summary: "两种服务的核心流程一致,差异主要体现在出具机构、时效与价格上。",
|
||||
keywords: ["实物鉴定", "中检鉴定", "服务区别"],
|
||||
updated_at: "2026-04-21 09:00:00",
|
||||
is_recommended: true,
|
||||
content_blocks: [
|
||||
"实物鉴定和中检鉴定都会经过下单、填写信息、上传资料、寄送商品、鉴定和查看报告这几个核心步骤。",
|
||||
"两者最大的区别在于出具机构不同。实物鉴定由安心验提供标准实物鉴定服务;中检鉴定由更高规格合作机构提供服务,适合对机构资质有更高要求的场景。",
|
||||
"中检鉴定通常价格更高、时效也会略长一些。下单前建议先根据您的使用场景、预算和时效要求选择合适服务。",
|
||||
],
|
||||
},
|
||||
related_articles: [
|
||||
{
|
||||
id: 2,
|
||||
title: "一般多久可以出结果?",
|
||||
category: "service",
|
||||
category_text: "服务流程",
|
||||
summary: "标准版通常 48 小时左右,具体取决于服务类型、资料完整度和物流节点。",
|
||||
keywords: ["时效", "出结果", "多久"],
|
||||
updated_at: "2026-04-21 09:00:00",
|
||||
is_recommended: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const settingsFallback: SettingsData = {
|
||||
profile_info: {
|
||||
user_id: 1,
|
||||
nickname: "安心验体验用户",
|
||||
mobile: "13800000000",
|
||||
avatar: "",
|
||||
status: "enabled",
|
||||
status_text: "账号正常",
|
||||
password_set: false,
|
||||
},
|
||||
preferences: {
|
||||
notify_order: true,
|
||||
notify_report: true,
|
||||
notify_supplement: true,
|
||||
notify_ticket: true,
|
||||
marketing_notify: false,
|
||||
privacy_mode: false,
|
||||
},
|
||||
legal_entries: [
|
||||
{
|
||||
code: "privacy_policy",
|
||||
title: "隐私说明",
|
||||
desc: "了解平台如何处理您的订单与联系方式信息",
|
||||
target_url: "/pages/help/index?q=%E9%9A%90%E7%A7%81",
|
||||
},
|
||||
{
|
||||
code: "service_notice",
|
||||
title: "服务与通知说明",
|
||||
desc: "了解消息提醒、工单回复与服务相关通知逻辑",
|
||||
target_url: "/pages/help/index?q=%E6%9C%8D%E5%8A%A1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const messagesFallback: UserMessageListData = {
|
||||
list: [
|
||||
{
|
||||
id: 3,
|
||||
title: "报告已出具",
|
||||
content: "您的正式报告已生成,可前往报告中心查看并完成验真。",
|
||||
biz_type: "report",
|
||||
biz_type_text: "报告通知",
|
||||
category: "report",
|
||||
category_text: "报告",
|
||||
biz_id: 1,
|
||||
is_read: false,
|
||||
created_at: "2026-04-20 20:55:17",
|
||||
target_url: "/pages/report/detail?id=1",
|
||||
target_label: "查看报告",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "请补充鉴定资料",
|
||||
content: "鉴定师需要您补充资料后继续处理,请尽快进入订单详情查看。",
|
||||
biz_type: "order",
|
||||
biz_type_text: "订单通知",
|
||||
category: "order",
|
||||
category_text: "订单",
|
||||
biz_id: 1,
|
||||
is_read: false,
|
||||
created_at: "2026-04-20 11:16:00",
|
||||
target_url: "/pages/order/detail?id=1",
|
||||
target_label: "查看订单",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "订单提交成功",
|
||||
content: "您的鉴定订单已提交成功,可前往订单中心查看进度。",
|
||||
biz_type: "order",
|
||||
biz_type_text: "订单通知",
|
||||
category: "order",
|
||||
category_text: "订单",
|
||||
biz_id: 1,
|
||||
is_read: true,
|
||||
created_at: "2026-04-20 09:12:00",
|
||||
target_url: "/pages/order/detail?id=1",
|
||||
target_label: "查看订单",
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
total_count: 3,
|
||||
unread_count: 2,
|
||||
category_counts: {
|
||||
all: 3,
|
||||
order: 2,
|
||||
report: 1,
|
||||
supplement: 0,
|
||||
ticket: 0,
|
||||
},
|
||||
current_count: 3,
|
||||
current_category: "all",
|
||||
unread_only: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const ticketOverviewFallback: TicketOverviewCard[] = [
|
||||
{ title: "全部工单", value: 2, desc: "您当前已提交的全部客服工单" },
|
||||
{ title: "待处理", value: 1, desc: "客服待处理或正在跟进中的工单" },
|
||||
{ title: "已解决", value: 1, desc: "已处理完成的工单数量" },
|
||||
{ title: "工单留言", value: 4, desc: "您与客服之间的全部沟通记录" },
|
||||
];
|
||||
|
||||
export const ticketsFallback: UserTicketListItem[] = [
|
||||
{
|
||||
id: 2,
|
||||
ticket_no: "TK202604200002",
|
||||
ticket_type: "report_issue",
|
||||
ticket_type_text: "报告问题",
|
||||
status: "pending",
|
||||
status_text: "待处理",
|
||||
priority: "normal",
|
||||
priority_text: "普通",
|
||||
title: "报告内容咨询",
|
||||
order_id: 3,
|
||||
latest_message: "请问 A 级和估值区间的口径是什么?",
|
||||
updated_at: "2026-04-20 12:11:00",
|
||||
created_at: "2026-04-20 12:10:00",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
ticket_no: "TK202604200001",
|
||||
ticket_type: "upload_issue",
|
||||
ticket_type_text: "上传问题",
|
||||
status: "processing",
|
||||
status_text: "处理中",
|
||||
priority: "high",
|
||||
priority_text: "高优先级",
|
||||
title: "补图说明咨询",
|
||||
order_id: 1,
|
||||
latest_message: "您好,请优先拍摄标签整体区域,再补一张放大近照,保证编码内容完整可辨认。",
|
||||
updated_at: "2026-04-20 11:25:00",
|
||||
created_at: "2026-04-20 11:18:00",
|
||||
},
|
||||
];
|
||||
|
||||
export const ticketDetailFallback: UserTicketDetailData = {
|
||||
ticket_info: {
|
||||
id: 1,
|
||||
ticket_no: "TK202604200001",
|
||||
ticket_type: "upload_issue",
|
||||
ticket_type_text: "上传问题",
|
||||
status: "processing",
|
||||
status_text: "处理中",
|
||||
priority: "high",
|
||||
priority_text: "高优先级",
|
||||
title: "补图说明咨询",
|
||||
content: "我不确定编码标签应该怎么拍,担心影响鉴定结果。",
|
||||
order_id: 1,
|
||||
created_at: "2026-04-20 11:18:00",
|
||||
updated_at: "2026-04-20 11:25:00",
|
||||
},
|
||||
order_info: {
|
||||
order_id: 1,
|
||||
order_no: "AXY202604200001",
|
||||
display_status: "等待您补充资料",
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
sender_type: "user",
|
||||
sender_type_text: "您",
|
||||
content: "我不确定编码标签应该怎么拍,担心影响鉴定结果。",
|
||||
attachments: [],
|
||||
created_at: "2026-04-20 11:18:00",
|
||||
},
|
||||
{
|
||||
sender_type: "customer_service",
|
||||
sender_type_text: "客服",
|
||||
content: "您好,请优先拍摄标签整体区域,再补一张放大近照,保证编码内容完整可辨认。",
|
||||
attachments: [],
|
||||
created_at: "2026-04-20 11:25:00",
|
||||
},
|
||||
],
|
||||
};
|
||||
200
user-app/src/pages.json
Normal file
200
user-app/src/pages.json
Normal file
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/auth/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/home/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "安心验",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/appraisal/service",
|
||||
"style": {
|
||||
"navigationBarTitleText": "选择鉴定服务"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/appraisal/product",
|
||||
"style": {
|
||||
"navigationBarTitleText": "选择商品信息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/appraisal/extra",
|
||||
"style": {
|
||||
"navigationBarTitleText": "补充商品信息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/appraisal/upload",
|
||||
"style": {
|
||||
"navigationBarTitleText": "上传鉴定资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/appraisal/confirm",
|
||||
"style": {
|
||||
"navigationBarTitleText": "确认订单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/appraisal/success",
|
||||
"style": {
|
||||
"navigationBarTitleText": "提交成功"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/shipping",
|
||||
"style": {
|
||||
"navigationBarTitleText": "查看寄送"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/supplement",
|
||||
"style": {
|
||||
"navigationBarTitleText": "补充资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/report/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报告详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/verify/result",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报告验真"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/material-tag/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "吊牌验真"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/report/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报告",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/message/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/help/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "帮助中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/help/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "帮助详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/address/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "地址管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/address/edit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "编辑地址"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/settings/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/settings/password",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录密码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/support/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "客服支持"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/support/create",
|
||||
"style": {
|
||||
"navigationBarTitleText": "发起工单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/support/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工单详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#6b6b6b",
|
||||
"selectedColor": "#151515",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/home/index",
|
||||
"text": "首页"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/order/index",
|
||||
"text": "订单"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/report/index",
|
||||
"text": "报告"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mine/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "安心验",
|
||||
"navigationBarBackgroundColor": "#FBFAF7",
|
||||
"backgroundColor": "#FBFAF7"
|
||||
}
|
||||
}
|
||||
164
user-app/src/pages/address/edit.vue
Normal file
164
user-app/src/pages/address/edit.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appApi } from "../../api/app";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const addressId = ref(0);
|
||||
const saving = ref(false);
|
||||
const returnTo = ref("");
|
||||
const recentCreatedAddressStorageKey = "anxinyan_recent_created_address_id";
|
||||
const form = ref({
|
||||
consignee: "",
|
||||
mobile: "",
|
||||
province: "",
|
||||
city: "",
|
||||
district: "",
|
||||
detail_address: "",
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
function handleDefaultChange(event: any) {
|
||||
form.value.is_default = !!event?.detail?.value;
|
||||
}
|
||||
|
||||
async function saveAddress() {
|
||||
const payload = form.value;
|
||||
if (!payload.consignee.trim() || !payload.mobile.trim() || !payload.province.trim() || !payload.city.trim() || !payload.district.trim() || !payload.detail_address.trim()) {
|
||||
showInfoToast("请完整填写地址信息");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await withLoading("正在保存地址", async () =>
|
||||
appApi.saveAddress({
|
||||
id: addressId.value || undefined,
|
||||
consignee: payload.consignee.trim(),
|
||||
mobile: payload.mobile.trim(),
|
||||
province: payload.province.trim(),
|
||||
city: payload.city.trim(),
|
||||
district: payload.district.trim(),
|
||||
detail_address: payload.detail_address.trim(),
|
||||
is_default: payload.is_default,
|
||||
}),
|
||||
);
|
||||
showInfoToast("地址已保存");
|
||||
if (returnTo.value === "appraisal_confirm") {
|
||||
uni.setStorageSync(recentCreatedAddressStorageKey, result.id);
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/address/index" });
|
||||
} catch (error) {
|
||||
showErrorToast(error, "地址保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
addressId.value = Number(options?.id || 0);
|
||||
returnTo.value = String(options?.return_to || "");
|
||||
if (!addressId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await appApi.getAddressDetail(addressId.value);
|
||||
form.value = {
|
||||
consignee: data.consignee,
|
||||
mobile: data.mobile,
|
||||
province: data.province,
|
||||
city: data.city,
|
||||
district: data.district,
|
||||
detail_address: data.detail_address,
|
||||
is_default: data.is_default,
|
||||
};
|
||||
} catch (error) {
|
||||
showErrorToast(error, "地址详情加载失败");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view class="page-hero page-hero--layered">
|
||||
<view class="tag tag--accent">{{ addressId ? "编辑地址" : "新增地址" }}</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
{{ addressId ? "编辑常用地址" : "新增常用地址" }}
|
||||
</view>
|
||||
<view class="section__desc">请填写完整地址信息,默认地址会优先用于后续联络和寄送场景。</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">收件人</view>
|
||||
<view class="field-box">
|
||||
<input v-model="form.consignee" class="field-input" maxlength="20" placeholder="请输入收件人姓名" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">联系电话</view>
|
||||
<view class="field-box">
|
||||
<input v-model="form.mobile" class="field-input" maxlength="20" placeholder="请输入手机号" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">省份</view>
|
||||
<view class="field-box">
|
||||
<input v-model="form.province" class="field-input" maxlength="20" placeholder="例如:广东省" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">城市</view>
|
||||
<view class="field-box">
|
||||
<input v-model="form.city" class="field-input" maxlength="20" placeholder="例如:深圳市" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">区县</view>
|
||||
<view class="field-box">
|
||||
<input v-model="form.district" class="field-input" maxlength="20" placeholder="例如:南山区" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">详细地址</view>
|
||||
<view class="textarea-box">
|
||||
<textarea
|
||||
v-model="form.detail_address"
|
||||
class="textarea-box__input"
|
||||
maxlength="120"
|
||||
placeholder="请输入街道、楼栋、门牌号等详细信息"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="task-card__row">
|
||||
<view>
|
||||
<view class="form-group__label">设为默认地址</view>
|
||||
<view class="form-group__hint">默认地址会优先展示在地址列表顶部。</view>
|
||||
</view>
|
||||
<switch :checked="form.is_default" color="#edbd00" @change="handleDefaultChange" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">取消</view>
|
||||
<view :class="['btn', 'btn--primary', saving ? 'btn--disabled' : '']" @click="saveAddress">
|
||||
{{ saving ? "保存中..." : "保存地址" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
157
user-app/src/pages/address/index.vue
Normal file
157
user-app/src/pages/address/index.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type UserAddressItem } from "../../api/app";
|
||||
import { resolveErrorMessage, showErrorToast, showInfoToast } from "../../utils/feedback";
|
||||
import { getPrivacyMode, maskAddress, maskMobile } from "../../utils/privacy";
|
||||
|
||||
const addresses = ref<UserAddressItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const privacyMode = ref(getPrivacyMode());
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const addressStats = computed(() => ({
|
||||
total: addresses.value.length,
|
||||
defaults: addresses.value.filter((item) => item.is_default).length,
|
||||
}));
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
async function fetchAddresses() {
|
||||
loading.value = true;
|
||||
privacyMode.value = getPrivacyMode();
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
const data = await appApi.getAddresses();
|
||||
addresses.value = data.list;
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("addresses fallback", error);
|
||||
if (!pageReady.value) {
|
||||
loadError.value = resolveErrorMessage(error, "地址加载失败,请稍后重试。");
|
||||
} else {
|
||||
showErrorToast(error, "地址加载失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createAddress() {
|
||||
uni.navigateTo({ url: "/pages/address/edit" });
|
||||
}
|
||||
|
||||
function editAddress(id: number) {
|
||||
uni.navigateTo({ url: `/pages/address/edit?id=${id}` });
|
||||
}
|
||||
|
||||
async function setDefault(id: number) {
|
||||
try {
|
||||
await appApi.setDefaultAddress(id);
|
||||
showInfoToast("已设为默认地址");
|
||||
await fetchAddresses();
|
||||
} catch (error) {
|
||||
showErrorToast(error, "默认地址设置失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAddress(id: number) {
|
||||
uni.showModal({
|
||||
title: "删除地址",
|
||||
content: "删除后将无法恢复,确认继续吗?",
|
||||
success: async (result) => {
|
||||
if (!result.confirm) return;
|
||||
try {
|
||||
await appApi.deleteAddress(id);
|
||||
showInfoToast("地址已删除");
|
||||
await fetchAddresses();
|
||||
} catch (error) {
|
||||
showErrorToast(error, "地址删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onShow(fetchAddresses);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载地址</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在读取您的地址信息。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">地址加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="fetchAddresses">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="page-hero page-hero--layered">
|
||||
<view class="page-hero__eyebrow">地址管理</view>
|
||||
<view class="page-title">常用寄送与收货地址</view>
|
||||
<view class="page-subtitle">维护常用地址后,后续填写寄送信息和售后联络会更高效。</view>
|
||||
<view class="hero-actions">
|
||||
<view class="hero-actions__item hero-actions__item--gold" @click="createAddress">新增地址</view>
|
||||
</view>
|
||||
<view class="hero-metrics">
|
||||
<view class="hero-metric">
|
||||
<view class="hero-metric__value">{{ addressStats.total }}</view>
|
||||
<view class="hero-metric__label">地址总数</view>
|
||||
</view>
|
||||
<view class="hero-metric">
|
||||
<view class="hero-metric__value">{{ addressStats.defaults }}</view>
|
||||
<view class="hero-metric__label">默认地址</view>
|
||||
</view>
|
||||
<view class="hero-metric">
|
||||
<view class="hero-metric__value">1</view>
|
||||
<view class="hero-metric__label">优先联络</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载地址</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在读取您的地址信息。</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in addresses"
|
||||
:key="item.id"
|
||||
class="section address-card section-card"
|
||||
>
|
||||
<view class="address-card__top">
|
||||
<view style="display:flex; align-items:center; gap:12rpx; flex-wrap:wrap;">
|
||||
<text class="address-card__name">{{ item.consignee }}</text>
|
||||
<text class="certificate-meta-chip">{{ maskMobile(item.mobile, privacyMode) }}</text>
|
||||
<text v-if="item.is_default" class="tag tag--accent">默认地址</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="address-card__body">{{ maskAddress(item.full_address, privacyMode) }}</view>
|
||||
<view class="address-card__footer">
|
||||
<text class="btn btn--ghost" @click="editAddress(item.id)">编辑</text>
|
||||
<text v-if="!item.is_default" class="btn btn--ghost" @click="setDefault(item.id)">设为默认</text>
|
||||
<text class="btn btn--ghost" @click="removeAddress(item.id)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && !addresses.length" class="section section-card section-note">
|
||||
<view class="section__title">还没有地址</view>
|
||||
<view class="section__desc">新增一个常用地址后,后续联系客服或寄送商品时会更方便。</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">返回</view>
|
||||
<view class="btn btn--primary" @click="createAddress">新增地址</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
483
user-app/src/pages/appraisal/confirm.vue
Normal file
483
user-app/src/pages/appraisal/confirm.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { appraisalApi } from "../../api/appraisal";
|
||||
import { appApi, type UserAddressItem } from "../../api/app";
|
||||
import FlowStepHeader from "../../components/FlowStepHeader.vue";
|
||||
import { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const store = useAppraisalStore();
|
||||
const preview = computed(() => store.preview);
|
||||
const loading = computed(() => !store.preview);
|
||||
const submitting = computed(() => false);
|
||||
const purchasePriceText = computed(() => {
|
||||
const price = Number(preview.value?.product_summary.price || 0);
|
||||
return price > 0 ? `¥${price}` : "未填写";
|
||||
});
|
||||
const addressSheetVisible = ref(false);
|
||||
const addressesLoading = ref(false);
|
||||
const addressOptions = ref<UserAddressItem[]>([]);
|
||||
const selectedReturnAddress = computed(() => store.returnAddress);
|
||||
const recentCreatedAddressStorageKey = "anxinyan_recent_created_address_id";
|
||||
|
||||
function applySelectedAddress(item: UserAddressItem) {
|
||||
store.setReturnAddress({
|
||||
id: item.id,
|
||||
consignee: item.consignee,
|
||||
mobile: item.mobile,
|
||||
province: item.province,
|
||||
city: item.city,
|
||||
district: item.district,
|
||||
detailAddress: item.detail_address,
|
||||
fullAddress: item.full_address,
|
||||
isDefault: item.is_default,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAddresses() {
|
||||
addressesLoading.value = true;
|
||||
try {
|
||||
const data = await appApi.getAddresses();
|
||||
addressOptions.value = data.list;
|
||||
|
||||
const recentCreatedAddressId = Number(uni.getStorageSync(recentCreatedAddressStorageKey) || 0);
|
||||
if (recentCreatedAddressId) {
|
||||
const recentAddress = data.list.find((item) => item.id === recentCreatedAddressId);
|
||||
uni.removeStorageSync(recentCreatedAddressStorageKey);
|
||||
if (recentAddress) {
|
||||
applySelectedAddress(recentAddress);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!store.returnAddress.id && data.list.length) {
|
||||
const fallback = data.list.find((item) => item.is_default) || data.list[0];
|
||||
applySelectedAddress(fallback);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, "地址信息加载失败");
|
||||
} finally {
|
||||
addressesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createAddressFromSheet() {
|
||||
closeAddressSheet();
|
||||
uni.navigateTo({ url: "/pages/address/edit?return_to=appraisal_confirm" });
|
||||
}
|
||||
|
||||
function openAddressSheet() {
|
||||
if (!addressOptions.value.length) {
|
||||
uni.showModal({
|
||||
title: "还没有地址",
|
||||
content: "请先新增一个常用地址,作为鉴定完成后的寄回地址。",
|
||||
success: (result) => {
|
||||
if (!result.confirm) return;
|
||||
createAddressFromSheet();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
addressSheetVisible.value = true;
|
||||
}
|
||||
|
||||
function closeAddressSheet() {
|
||||
addressSheetVisible.value = false;
|
||||
}
|
||||
|
||||
function chooseAddress(item: UserAddressItem) {
|
||||
applySelectedAddress(item);
|
||||
closeAddressSheet();
|
||||
showInfoToast("寄回地址已选择");
|
||||
}
|
||||
|
||||
async function goSuccess() {
|
||||
if (!store.draftId) {
|
||||
showInfoToast("订单信息未准备完成,请返回上一步检查");
|
||||
return;
|
||||
}
|
||||
if (!store.returnAddress.id) {
|
||||
showInfoToast("请先确认寄回地址");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await withLoading("正在创建订单", async () => {
|
||||
try {
|
||||
return await appraisalApi.submit(store.draftId, store.returnAddress.id);
|
||||
} catch (error) {
|
||||
if (!isMissingDraftError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await rebuildDraftFromStore(store);
|
||||
return appraisalApi.submit(store.draftId, store.returnAddress.id);
|
||||
}
|
||||
});
|
||||
const successUrl = `/pages/appraisal/success?order_no=${encodeURIComponent(result.order_no)}&appraisal_no=${encodeURIComponent(result.appraisal_no)}&order_id=${result.order_id}`;
|
||||
store.resetForNewFlow();
|
||||
uni.navigateTo({
|
||||
url: successUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorToast(error, "创建订单失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
function openAgreement(url: string) {
|
||||
if (!url) return;
|
||||
uni.navigateTo({ url });
|
||||
}
|
||||
|
||||
onLoad(async () => {
|
||||
store.hydrate();
|
||||
await fetchAddresses();
|
||||
if (!store.draftId) return;
|
||||
try {
|
||||
let data;
|
||||
try {
|
||||
data = await appraisalApi.preview(store.draftId);
|
||||
} catch (error) {
|
||||
if (!isMissingDraftError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await rebuildDraftFromStore(store);
|
||||
data = await appraisalApi.preview(store.draftId);
|
||||
showInfoToast("草稿已自动恢复,请确认订单信息后继续提交。");
|
||||
}
|
||||
store.setPreview(data);
|
||||
} catch (error) {
|
||||
showErrorToast(error, "订单预览加载失败");
|
||||
}
|
||||
});
|
||||
|
||||
onShow(fetchAddresses);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<FlowStepHeader
|
||||
:step="6"
|
||||
title="确认订单并支付"
|
||||
desc="提交前请再次确认服务、商品资料与寄回地址,提交后会生成正式鉴定单并进入履约流程。"
|
||||
:chips="['确认商品信息', '确认资料', '提交后生成正式鉴定单']"
|
||||
/>
|
||||
|
||||
<view v-if="loading" class="notice-card">
|
||||
<view class="notice-card__title">正在准备订单预览</view>
|
||||
<view class="notice-card__desc">请稍候,系统正在汇总服务、商品与费用信息。</view>
|
||||
</view>
|
||||
|
||||
<view class="section summary-card">
|
||||
<view class="summary-card__title">服务摘要</view>
|
||||
<view class="summary-card__row">
|
||||
<text class="summary-card__label">鉴定服务</text>
|
||||
<text class="summary-card__value">{{ preview?.service_summary.service_provider_text || '中检鉴定' }}</text>
|
||||
</view>
|
||||
<view class="summary-card__row">
|
||||
<text class="summary-card__label">资料进度</text>
|
||||
<text class="summary-card__value">{{ preview?.upload_summary.uploaded_count || 0 }} 项资料已完成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section summary-card">
|
||||
<view class="summary-card__title">商品摘要</view>
|
||||
<view class="summary-card__row">
|
||||
<text class="summary-card__label">商品名称</text>
|
||||
<text class="summary-card__value">{{ preview?.product_summary.product_name || '' }}</text>
|
||||
</view>
|
||||
<view class="summary-card__row">
|
||||
<text class="summary-card__label">品类 / 品牌</text>
|
||||
<text class="summary-card__value">{{ preview?.product_summary.category_name || '' }} / {{ preview?.product_summary.brand_name || '' }}</text>
|
||||
</view>
|
||||
<view class="summary-card__row">
|
||||
<text class="summary-card__label">购买价格</text>
|
||||
<text class="summary-card__value">{{ purchasePriceText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section summary-card">
|
||||
<view class="summary-card__title">费用明细</view>
|
||||
<view class="summary-card__row">
|
||||
<text class="summary-card__label">鉴定服务费</text>
|
||||
<text class="summary-card__value">¥{{ preview?.fee_detail.service_fee || 0 }}</text>
|
||||
</view>
|
||||
<view class="summary-card__row">
|
||||
<text class="summary-card__label">优惠抵扣</text>
|
||||
<text class="summary-card__value">- ¥{{ preview?.fee_detail.discount_fee || 0 }}</text>
|
||||
</view>
|
||||
<view class="summary-card__row">
|
||||
<text class="summary-card__label">实付金额</text>
|
||||
<text class="summary-card__value">¥{{ preview?.fee_detail.pay_amount || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section summary-card">
|
||||
<view class="summary-card__title">寄回地址</view>
|
||||
<view v-if="selectedReturnAddress.id" class="address-preview-card">
|
||||
<view class="address-preview-card__top">
|
||||
<view>
|
||||
<view class="address-preview-card__name">{{ selectedReturnAddress.consignee }}</view>
|
||||
<view class="address-preview-card__mobile">{{ selectedReturnAddress.mobile }}</view>
|
||||
</view>
|
||||
<text v-if="selectedReturnAddress.isDefault" class="certificate-meta-chip">默认地址</text>
|
||||
</view>
|
||||
<view class="address-preview-card__address">{{ selectedReturnAddress.fullAddress }}</view>
|
||||
<view class="address-preview-card__action" @click="openAddressSheet">更换地址</view>
|
||||
</view>
|
||||
<view v-else class="address-preview-card address-preview-card--empty">
|
||||
<view class="address-preview-card__name">暂未选择寄回地址</view>
|
||||
<view class="section__desc">鉴定完成后,平台会按这里的地址回寄商品,请先确认。</view>
|
||||
<view class="address-preview-card__action" @click="openAddressSheet">选择地址</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section agreement-card">
|
||||
<view class="section__title">提交前确认</view>
|
||||
<view class="section__desc">提交即表示您已阅读并同意以下协议与说明,请在提交前再次确认。</view>
|
||||
<view v-if="preview?.agreements?.length" class="agreement-card__list">
|
||||
<view
|
||||
v-for="item in preview.agreements"
|
||||
:key="item.code"
|
||||
class="agreement-card__item"
|
||||
@click="openAgreement(item.target_url)"
|
||||
>
|
||||
<view class="agreement-card__item-title">{{ item.title }}</view>
|
||||
<view class="agreement-card__item-desc">{{ item.desc }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">返回</view>
|
||||
<view :class="['btn', 'btn--primary', loading ? 'btn--disabled' : '']" @click="goSuccess">立即支付</view>
|
||||
</view>
|
||||
|
||||
<view v-if="addressSheetVisible" class="picker-sheet">
|
||||
<view class="picker-sheet__mask" @click="closeAddressSheet"></view>
|
||||
<view class="picker-sheet__panel">
|
||||
<view class="picker-sheet__header">
|
||||
<view>
|
||||
<view class="picker-sheet__title">选择寄回地址</view>
|
||||
<view class="picker-sheet__desc">订单完成后,鉴定物品会按这里的地址寄回给您。</view>
|
||||
</view>
|
||||
<view class="picker-sheet__close" @click="closeAddressSheet">关闭</view>
|
||||
</view>
|
||||
|
||||
<view class="picker-sheet__toolbar">
|
||||
<view class="picker-sheet__create" @click="createAddressFromSheet">新建地址</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="picker-sheet__list">
|
||||
<view
|
||||
v-for="item in addressOptions"
|
||||
:key="item.id"
|
||||
:class="['picker-sheet__option', selectedReturnAddress.id === item.id ? 'picker-sheet__option--selected' : '']"
|
||||
@click="chooseAddress(item)"
|
||||
>
|
||||
<view style="flex:1">
|
||||
<view class="picker-sheet__option-title">{{ item.consignee }} / {{ item.mobile }}</view>
|
||||
<view class="selector-card__meta" style="margin-top: 10rpx;">{{ item.full_address }}</view>
|
||||
</view>
|
||||
<view class="picker-sheet__option-action">{{ selectedReturnAddress.id === item.id ? "已选地址" : "选择" }}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.address-preview-card {
|
||||
margin-top: 16rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.address-preview-card--empty {
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.address-preview-card__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.address-preview-card__name {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.address-preview-card__mobile {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.address-preview-card__address {
|
||||
margin-top: 14rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.address-preview-card__action {
|
||||
margin-top: 16rpx;
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.agreement-card__list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.agreement-card__item {
|
||||
padding: 18rpx 20rpx;
|
||||
border-radius: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.agreement-card__item-title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.agreement-card__item-desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.picker-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 160;
|
||||
}
|
||||
|
||||
.picker-sheet__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 20, 18, 0.38);
|
||||
}
|
||||
|
||||
.picker-sheet__panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -12rpx 36rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.picker-sheet__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.picker-sheet__title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.picker-sheet__desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.picker-sheet__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 104rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(237, 189, 0, 0.12);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-sheet__list {
|
||||
max-height: 58vh;
|
||||
}
|
||||
|
||||
.picker-sheet__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.picker-sheet__create {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 144rpx;
|
||||
padding: 14rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, rgba(237, 189, 0, 0.16) 0%, rgba(237, 189, 0, 0.08) 100%);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.picker-sheet__option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 6rpx;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.picker-sheet__option--selected {
|
||||
padding: 24rpx 18rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(237, 189, 0, 0.22);
|
||||
box-shadow: 0 10rpx 24rpx rgba(237, 189, 0, 0.08);
|
||||
}
|
||||
|
||||
.picker-sheet__option-title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.picker-sheet__option-action {
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
</style>
|
||||
272
user-app/src/pages/appraisal/extra.vue
Normal file
272
user-app/src/pages/appraisal/extra.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appraisalApi } from "../../api/appraisal";
|
||||
import FlowStepHeader from "../../components/FlowStepHeader.vue";
|
||||
import { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const store = useAppraisalStore();
|
||||
const remarkDraft = ref("");
|
||||
const submitting = ref(false);
|
||||
const loadError = ref("");
|
||||
const extraDraftFields = [
|
||||
"purchase_channel",
|
||||
"purchase_price",
|
||||
"purchase_date",
|
||||
"usage_status",
|
||||
"condition_desc",
|
||||
"accessories_json",
|
||||
"remark",
|
||||
];
|
||||
const legacyDemoExtra = {
|
||||
purchase_channel: "专柜",
|
||||
purchase_price: 8500,
|
||||
purchase_date: "",
|
||||
usage_status: "light_use",
|
||||
condition_desc: "轻微使用痕迹",
|
||||
accessories: ["防尘袋", "包装盒"],
|
||||
remark: "",
|
||||
};
|
||||
|
||||
const extraSummary = computed(() => [
|
||||
store.extra.purchaseChannel || "未选购买渠道",
|
||||
store.extra.purchasePrice ? `¥${store.extra.purchasePrice}` : "未填购买价格",
|
||||
store.extra.usageStatus === "new" ? "全新未使用" : store.extra.usageStatus === "light_use" ? "轻微使用痕迹" : store.extra.usageStatus === "used" ? "长期使用" : "未选使用情况",
|
||||
].join(" · "));
|
||||
|
||||
function errorTitle() {
|
||||
return loadError.value.includes("保存失败") ? "补充信息保存失败" : "流程状态异常";
|
||||
}
|
||||
|
||||
function setPurchaseChannel(value: string) {
|
||||
store.setExtra({ purchaseChannel: value });
|
||||
}
|
||||
|
||||
function setUsageStatus(value: string) {
|
||||
store.setExtra({ usageStatus: value });
|
||||
}
|
||||
|
||||
function toggleAccessory(value: string) {
|
||||
const next = store.extra.accessories.includes(value)
|
||||
? store.extra.accessories.filter((item) => item !== value)
|
||||
: [...store.extra.accessories, value];
|
||||
store.setExtra({ accessories: next });
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
if (submitting.value) return;
|
||||
if (!store.extra.purchasePrice) {
|
||||
showInfoToast("请先填写购买价格");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
store.setExtra({ remark: remarkDraft.value });
|
||||
try {
|
||||
await withLoading("正在保存补充信息", async () => {
|
||||
const payload = {
|
||||
draft_id: store.draftId,
|
||||
current_step: 3,
|
||||
extra_info: {
|
||||
purchase_channel: store.extra.purchaseChannel,
|
||||
purchase_price: Number(store.extra.purchasePrice || 0),
|
||||
purchase_date: store.extra.purchaseDate,
|
||||
usage_status: store.extra.usageStatus,
|
||||
condition_desc: store.extra.conditionDesc,
|
||||
accessories: store.extra.accessories,
|
||||
remark: store.extra.remark,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await appraisalApi.saveDraft(payload);
|
||||
} catch (error) {
|
||||
if (!isMissingDraftError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await rebuildDraftFromStore(store);
|
||||
await appraisalApi.saveDraft({
|
||||
...payload,
|
||||
draft_id: store.draftId,
|
||||
});
|
||||
}
|
||||
});
|
||||
loadError.value = "";
|
||||
store.setCurrentStep(3);
|
||||
uni.navigateTo({ url: "/pages/appraisal/upload" });
|
||||
} catch (error) {
|
||||
loadError.value = "补充信息保存失败,请稍后重试。";
|
||||
showErrorToast(error, "补充信息保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
function parseDraftAccessories(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.warn("parse accessories failed", error);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isLegacyDraftExtraInfo(extraInfo: Record<string, any> | undefined) {
|
||||
if (!extraInfo) return false;
|
||||
const accessories = parseDraftAccessories(extraInfo.accessories_json);
|
||||
return (
|
||||
extraInfo.purchase_channel === legacyDemoExtra.purchase_channel &&
|
||||
Number(extraInfo.purchase_price || 0) === legacyDemoExtra.purchase_price &&
|
||||
(extraInfo.purchase_date || "") === legacyDemoExtra.purchase_date &&
|
||||
extraInfo.usage_status === legacyDemoExtra.usage_status &&
|
||||
extraInfo.condition_desc === legacyDemoExtra.condition_desc &&
|
||||
(extraInfo.remark || "") === legacyDemoExtra.remark &&
|
||||
accessories.length === legacyDemoExtra.accessories.length &&
|
||||
accessories.every((item, index) => item === legacyDemoExtra.accessories[index])
|
||||
);
|
||||
}
|
||||
|
||||
function hasSavedExtraInfo(extraInfo: Record<string, any> | undefined) {
|
||||
if (!extraInfo) return false;
|
||||
if (isLegacyDraftExtraInfo(extraInfo)) return false;
|
||||
return extraDraftFields.some((key) => {
|
||||
const value = extraInfo[key];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return value !== undefined && value !== null && value !== "" && value !== 0 && value !== "0" && value !== "0.00";
|
||||
});
|
||||
}
|
||||
|
||||
onLoad(async () => {
|
||||
store.hydrate();
|
||||
store.clearLegacyExtraDefaults();
|
||||
remarkDraft.value = store.extra.remark;
|
||||
if (store.draftId) {
|
||||
try {
|
||||
const draft = await appraisalApi.getDraft(store.draftId);
|
||||
if (hasSavedExtraInfo(draft.extra_info)) {
|
||||
const accessories = parseDraftAccessories(draft.extra_info.accessories_json);
|
||||
store.setExtra({
|
||||
purchaseChannel: draft.extra_info.purchase_channel || "",
|
||||
purchasePrice: draft.extra_info.purchase_price ? String(draft.extra_info.purchase_price) : "",
|
||||
purchaseDate: draft.extra_info.purchase_date || "",
|
||||
usageStatus: draft.extra_info.usage_status || "",
|
||||
conditionDesc: draft.extra_info.condition_desc || "",
|
||||
accessories,
|
||||
remark: draft.extra_info.remark || "",
|
||||
});
|
||||
remarkDraft.value = draft.extra_info.remark || "";
|
||||
} else {
|
||||
store.resetExtra();
|
||||
remarkDraft.value = "";
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingDraftError(error)) {
|
||||
try {
|
||||
await rebuildDraftFromStore(store);
|
||||
loadError.value = "";
|
||||
showInfoToast("草稿已自动恢复,可继续补充商品信息。");
|
||||
} catch (recoverError) {
|
||||
store.resetForNewFlow();
|
||||
loadError.value = "草稿已失效,请重新开始鉴定流程。";
|
||||
showErrorToast(recoverError, "草稿读取失败");
|
||||
}
|
||||
} else {
|
||||
loadError.value = "草稿信息读取失败,当前展示的是本地暂存内容。";
|
||||
showErrorToast(error, "草稿读取失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<FlowStepHeader
|
||||
:step="3"
|
||||
title="补充商品信息"
|
||||
desc="补充购买来源、价格、使用情况与附件信息,为后续估值和结果解释提供更完整依据。"
|
||||
:chips="['购买信息', '使用情况', '附件说明']"
|
||||
/>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">当前补充摘要</view>
|
||||
<view class="section__desc">这部分信息会进入后续估值说明与结果解释,建议尽量完整填写。</view>
|
||||
<view class="selection-summary-card">
|
||||
<view class="selection-summary-card__title">{{ extraSummary }}</view>
|
||||
<view class="selection-summary-card__desc">
|
||||
{{ store.extra.accessories.length ? `附件:${store.extra.accessories.join(" / ")}` : "附件情况可选填,上传越完整越有助于判断。" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-panel__title">购买与附件信息</view>
|
||||
<view v-if="loadError" class="notice-card">
|
||||
<view class="notice-card__title">{{ errorTitle() }}</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">购买渠道</view>
|
||||
<view class="chip-list">
|
||||
<text :class="['choice-chip', store.extra.purchaseChannel === '专柜' ? 'choice-chip--selected' : '']" @click="setPurchaseChannel('专柜')">专柜</text>
|
||||
<text :class="['choice-chip', store.extra.purchaseChannel === '官网' ? 'choice-chip--selected' : '']" @click="setPurchaseChannel('官网')">官网</text>
|
||||
<text :class="['choice-chip', store.extra.purchaseChannel === '代购' ? 'choice-chip--selected' : '']" @click="setPurchaseChannel('代购')">代购</text>
|
||||
<text :class="['choice-chip', store.extra.purchaseChannel === '其他' ? 'choice-chip--selected' : '']" @click="setPurchaseChannel('其他')">其他</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">购买价格</view>
|
||||
<view class="field-box">
|
||||
<input v-model="store.extra.purchasePrice" class="field-input" type="digit" placeholder="请输入购买价格" />
|
||||
</view>
|
||||
<view class="form-group__hint">用于估值和保险参考,不作为对外展示价格。</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">使用情况</view>
|
||||
<view class="chip-list">
|
||||
<text :class="['choice-chip', store.extra.usageStatus === 'new' ? 'choice-chip--selected' : '']" @click="setUsageStatus('new')">全新未使用</text>
|
||||
<text :class="['choice-chip', store.extra.usageStatus === 'light_use' ? 'choice-chip--selected' : '']" @click="setUsageStatus('light_use')">轻微使用痕迹</text>
|
||||
<text :class="['choice-chip', store.extra.usageStatus === 'used' ? 'choice-chip--selected' : '']" @click="setUsageStatus('used')">长期使用</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">附件情况</view>
|
||||
<view class="chip-list">
|
||||
<text :class="['choice-chip', store.extra.accessories.includes('防尘袋') ? 'choice-chip--selected' : '']" @click="toggleAccessory('防尘袋')">防尘袋</text>
|
||||
<text :class="['choice-chip', store.extra.accessories.includes('包装盒') ? 'choice-chip--selected' : '']" @click="toggleAccessory('包装盒')">包装盒</text>
|
||||
<text :class="['choice-chip', store.extra.accessories.includes('发票 / 小票') ? 'choice-chip--selected' : '']" @click="toggleAccessory('发票 / 小票')">发票 / 小票</text>
|
||||
<text :class="['choice-chip', store.extra.accessories.includes('保卡 / 证书') ? 'choice-chip--selected' : '']" @click="toggleAccessory('保卡 / 证书')">保卡 / 证书</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">补充说明</view>
|
||||
<view class="field-box">
|
||||
<input v-model="remarkDraft" class="field-input" placeholder="可补充商品来源、瑕疵情况或特殊说明" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">返回</view>
|
||||
<view :class="['btn', 'btn--primary', submitting ? 'btn--disabled' : '']" @click="goNext">
|
||||
{{ submitting ? "保存中..." : "下一步" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
570
user-app/src/pages/appraisal/product.vue
Normal file
570
user-app/src/pages/appraisal/product.vue
Normal file
@@ -0,0 +1,570 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appraisalApi, type CatalogOption, type CategoryOption } from "../../api/appraisal";
|
||||
import FlowStepHeader from "../../components/FlowStepHeader.vue";
|
||||
import { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
type PickerType = "category" | "brand";
|
||||
type CategoryPickerItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
desc: string;
|
||||
};
|
||||
|
||||
const store = useAppraisalStore();
|
||||
|
||||
const categories = ref<CategoryPickerItem[]>([]);
|
||||
const brands = ref<CatalogOption[]>([]);
|
||||
const pageLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const loadError = ref("");
|
||||
const activePicker = ref<PickerType | "">("");
|
||||
const pickerKeyword = ref("");
|
||||
|
||||
const canContinue = computed(() => Boolean(store.product.categoryId && store.product.brandId));
|
||||
|
||||
const productSummary = computed(() => [
|
||||
store.product.categoryName || "未选品类",
|
||||
store.product.brandName || "未选品牌",
|
||||
].join(" / "));
|
||||
|
||||
const pickerMeta = computed(() => {
|
||||
if (activePicker.value === "category") {
|
||||
return {
|
||||
title: "选择品类",
|
||||
desc: "品类将持续扩展,建议统一通过搜索选择器完成选择,后续更便于拓展更多鉴定业务。",
|
||||
options: categories.value,
|
||||
label: (item: CategoryPickerItem) => item.name || "",
|
||||
emptyText: "暂无可用品类",
|
||||
searchPlaceholder: "搜索品类名称",
|
||||
};
|
||||
}
|
||||
|
||||
if (activePicker.value === "brand") {
|
||||
return {
|
||||
title: "选择品牌",
|
||||
desc: "支持搜索品牌名称,选择后即可进入下一步。",
|
||||
options: brands.value,
|
||||
label: (item: CatalogOption) => item.brand_name || "",
|
||||
emptyText: "当前品类下暂无品牌数据",
|
||||
searchPlaceholder: "搜索品牌名称",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "选择品牌",
|
||||
desc: "支持搜索品牌名称,选择后即可进入下一步。",
|
||||
options: brands.value,
|
||||
label: (item: CatalogOption) => item.brand_name || "",
|
||||
emptyText: "当前品类下暂无品牌数据",
|
||||
searchPlaceholder: "搜索品牌名称",
|
||||
};
|
||||
});
|
||||
|
||||
const filteredPickerOptions = computed(() => {
|
||||
const keyword = pickerKeyword.value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return pickerMeta.value.options;
|
||||
}
|
||||
|
||||
const options = pickerMeta.value.options as Array<CatalogOption | CategoryPickerItem>;
|
||||
return options.filter((item) =>
|
||||
pickerMeta.value.label(item as never).toLowerCase().includes(keyword),
|
||||
);
|
||||
});
|
||||
|
||||
function pickerOptionKey(item: CatalogOption | CategoryPickerItem) {
|
||||
if (activePicker.value === "category") {
|
||||
return String((item as CategoryPickerItem).id);
|
||||
}
|
||||
|
||||
if (activePicker.value === "brand") {
|
||||
return String((item as CatalogOption).brand_id || "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function loadBrands() {
|
||||
if (!store.product.categoryId) {
|
||||
brands.value = [];
|
||||
return;
|
||||
}
|
||||
const data = await appraisalApi.getBrands(store.product.categoryId);
|
||||
brands.value = data.list;
|
||||
if (!store.product.brandId && brands.value.length) {
|
||||
selectBrand(brands.value[0], false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectCategory(item: CategoryPickerItem) {
|
||||
store.setProduct({
|
||||
categoryId: item.id,
|
||||
categoryName: item.name,
|
||||
brandId: 0,
|
||||
brandName: "",
|
||||
});
|
||||
brands.value = [];
|
||||
loadBrands();
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
const data = await appraisalApi.getCategories();
|
||||
categories.value = data.list.map((item: CategoryOption) => ({
|
||||
id: item.category_id,
|
||||
name: item.category_name,
|
||||
code: item.category_code,
|
||||
desc: "来自后台品类配置,支持后续继续扩展。",
|
||||
}));
|
||||
}
|
||||
|
||||
function selectBrand(item: CatalogOption, closeSheet = true) {
|
||||
store.setProduct({
|
||||
brandId: item.brand_id || 0,
|
||||
brandName: item.brand_name || "",
|
||||
});
|
||||
if (closeSheet) {
|
||||
closePicker();
|
||||
}
|
||||
}
|
||||
|
||||
function openPicker(type: PickerType) {
|
||||
activePicker.value = type;
|
||||
pickerKeyword.value = "";
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
activePicker.value = "";
|
||||
pickerKeyword.value = "";
|
||||
}
|
||||
|
||||
function confirmPicker(item: CatalogOption) {
|
||||
if (activePicker.value === "category") {
|
||||
selectCategory(item as unknown as CategoryPickerItem);
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
if (activePicker.value === "brand") {
|
||||
selectBrand(item);
|
||||
}
|
||||
}
|
||||
|
||||
function isPickerCurrent(item: CatalogOption | CategoryPickerItem) {
|
||||
if (activePicker.value === "category") {
|
||||
return Number((item as CategoryPickerItem).id) === Number(store.product.categoryId || 0);
|
||||
}
|
||||
if (activePicker.value === "brand") {
|
||||
return Number((item as CatalogOption).brand_id || 0) === Number(store.product.brandId || 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
if (submitting.value) return;
|
||||
if (!canContinue.value) {
|
||||
showInfoToast("请先完成品类和品牌选择");
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await withLoading("正在保存商品信息", async () => {
|
||||
await appraisalApi.saveDraft({
|
||||
draft_id: store.draftId,
|
||||
current_step: 2,
|
||||
service_provider: store.serviceProvider,
|
||||
product_info: {
|
||||
category_id: store.product.categoryId,
|
||||
brand_id: store.product.brandId,
|
||||
},
|
||||
});
|
||||
});
|
||||
loadError.value = "";
|
||||
store.setCurrentStep(2);
|
||||
uni.navigateTo({ url: "/pages/appraisal/extra" });
|
||||
} catch (error) {
|
||||
loadError.value = "商品信息保存失败,请确认字典接口是否正常。";
|
||||
showErrorToast(error, "商品信息保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
onLoad(async () => {
|
||||
store.hydrate();
|
||||
pageLoading.value = true;
|
||||
try {
|
||||
await loadCategories();
|
||||
if (!categories.value.length) {
|
||||
loadError.value = "后台暂未启用品类,请先在商品资料中心配置并启用品类。";
|
||||
store.setProduct({
|
||||
categoryId: 0,
|
||||
categoryName: "",
|
||||
brandId: 0,
|
||||
brandName: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!store.draftId) {
|
||||
const draft = await appraisalApi.createDraft(store.serviceProvider);
|
||||
store.setDraft(draft.draft_id);
|
||||
}
|
||||
|
||||
const draft = await appraisalApi.getDraft(store.draftId);
|
||||
if (draft.product_info?.category_id) {
|
||||
const categoryId = Number(draft.product_info.category_id);
|
||||
const categoryName = categories.value.find((item) => item.id === categoryId)?.name || "";
|
||||
store.setProduct({
|
||||
categoryId,
|
||||
categoryName,
|
||||
brandId: Number(draft.product_info.brand_id || 0),
|
||||
brandName: "",
|
||||
});
|
||||
} else if (store.product.categoryId) {
|
||||
const currentCategory = categories.value.find((item) => item.id === store.product.categoryId);
|
||||
if (!currentCategory) {
|
||||
store.setProduct({
|
||||
categoryId: 0,
|
||||
categoryName: "",
|
||||
brandId: 0,
|
||||
brandName: "",
|
||||
});
|
||||
} else if (store.product.categoryName !== currentCategory.name) {
|
||||
store.setProduct({ categoryName: currentCategory.name });
|
||||
}
|
||||
}
|
||||
|
||||
await loadBrands();
|
||||
|
||||
if (store.product.brandId) {
|
||||
const brand = brands.value.find((item) => item.brand_id === store.product.brandId);
|
||||
if (brand?.brand_name) {
|
||||
store.setProduct({ brandName: brand.brand_name });
|
||||
}
|
||||
}
|
||||
|
||||
loadError.value = "";
|
||||
} catch (error) {
|
||||
if (isMissingDraftError(error)) {
|
||||
try {
|
||||
await rebuildDraftFromStore(store);
|
||||
await loadBrands();
|
||||
loadError.value = "";
|
||||
showInfoToast("草稿已自动恢复,可继续填写商品信息。");
|
||||
} catch (recoverError) {
|
||||
store.resetForNewFlow();
|
||||
loadError.value = "商品草稿已失效,请重新选择鉴定服务。";
|
||||
showErrorToast(recoverError, "商品信息加载失败");
|
||||
}
|
||||
} else {
|
||||
loadError.value = "商品字典加载失败,请稍后重试或联系管理员检查接口。";
|
||||
showErrorToast(error, "商品信息加载失败");
|
||||
}
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<FlowStepHeader
|
||||
:step="2"
|
||||
title="选择商品信息"
|
||||
desc="只需确认本次送检商品的品类和品牌,其他细节可在后续补充说明中填写。"
|
||||
:chips="['选择品类', '选择品牌']"
|
||||
/>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-panel__title">当前识别路径</view>
|
||||
<view class="form-panel__desc">当前步骤仅用于确定商品大类和品牌,便于匹配资料模板与服务流程。</view>
|
||||
<view class="selection-summary-card">
|
||||
<view class="selection-summary-card__title">{{ productSummary }}</view>
|
||||
<view class="selection-summary-card__desc">
|
||||
{{ canContinue ? "已完成基础商品信息" : "请选择品类和品牌后继续" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-panel__title">商品识别信息</view>
|
||||
<view class="form-panel__desc">品牌列表会随品类变化,支持搜索快速定位。</view>
|
||||
|
||||
<view v-if="pageLoading" class="notice-card">
|
||||
<view class="notice-card__title">正在加载商品字典</view>
|
||||
<view class="notice-card__desc">品类和品牌数据加载完成后即可继续选择。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loadError" class="notice-card">
|
||||
<view class="notice-card__title">商品信息加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">品类</view>
|
||||
<view :class="['selector-card', store.product.categoryId ? 'selector-card--selected' : '']" @click="openPicker('category')">
|
||||
<view class="selector-card__main">
|
||||
<view class="selector-card__value">{{ store.product.categoryName || "请选择品类" }}</view>
|
||||
<view class="selector-card__meta">当前已配置 {{ categories.length }} 个品类,来源于后台商品资料配置。</view>
|
||||
</view>
|
||||
<view class="selector-card__side">
|
||||
<view v-if="store.product.categoryId" class="selector-card__badge">已选品类</view>
|
||||
<view class="selector-card__action">选择</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">品牌</view>
|
||||
<view :class="['selector-card', store.product.brandId ? 'selector-card--selected' : '']" @click="openPicker('brand')">
|
||||
<view class="selector-card__main">
|
||||
<view class="selector-card__value">{{ store.product.brandName || "请选择品牌" }}</view>
|
||||
<view class="selector-card__meta">当前品类下共 {{ brands.length }} 个品牌,支持搜索。</view>
|
||||
</view>
|
||||
<view class="selector-card__side">
|
||||
<view v-if="store.product.brandId" class="selector-card__badge">已选品牌</view>
|
||||
<view class="selector-card__action">选择</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activePicker" class="picker-sheet">
|
||||
<view class="picker-sheet__mask" @click="closePicker"></view>
|
||||
<view class="picker-sheet__panel">
|
||||
<view class="picker-sheet__header">
|
||||
<view>
|
||||
<view class="picker-sheet__title">{{ pickerMeta.title }}</view>
|
||||
<view class="picker-sheet__desc">{{ pickerMeta.desc }}</view>
|
||||
</view>
|
||||
<view class="picker-sheet__close" @click="closePicker">关闭</view>
|
||||
</view>
|
||||
|
||||
<view class="field-box">
|
||||
<input v-model="pickerKeyword" class="field-input" :placeholder="pickerMeta.searchPlaceholder" />
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="picker-sheet__list">
|
||||
<view
|
||||
v-for="item in filteredPickerOptions"
|
||||
:key="pickerOptionKey(item)"
|
||||
:class="['picker-sheet__option', isPickerCurrent(item as CatalogOption) ? 'picker-sheet__option--selected' : '']"
|
||||
@click="confirmPicker(item as CatalogOption)"
|
||||
>
|
||||
<view class="picker-sheet__option-title">{{ pickerMeta.label(item as never) }}</view>
|
||||
<view class="picker-sheet__option-action">{{ isPickerCurrent(item as CatalogOption) ? '已选' : '选择' }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="filteredPickerOptions.length === 0" class="picker-sheet__empty">
|
||||
{{ pickerMeta.emptyText }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">返回</view>
|
||||
<view :class="['btn', 'btn--primary', (!canContinue || submitting) ? 'btn--disabled' : '']" @click="goNext">
|
||||
{{ submitting ? "保存中..." : "下一步" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.selection-summary-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx 26rpx;
|
||||
border-radius: 26rpx;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(237, 189, 0, 0.18);
|
||||
}
|
||||
|
||||
.selection-summary-card__title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.selection-summary-card__desc {
|
||||
margin-top: 10rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.selector-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 24rpx 24rpx;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--input-border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.selector-card--disabled {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.selector-card--selected {
|
||||
border-color: rgba(237, 189, 0, 0.36);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(237, 189, 0, 0.18), transparent 32%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(247, 238, 223, 0.98) 100%);
|
||||
box-shadow:
|
||||
0 14rpx 30rpx rgba(237, 189, 0, 0.1),
|
||||
0 0 0 2rpx rgba(237, 189, 0, 0.08) inset;
|
||||
}
|
||||
|
||||
.selector-card__badge {
|
||||
position: static;
|
||||
min-height: 38rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(135deg, rgba(237, 189, 0, 0.16) 0%, rgba(237, 189, 0, 0.22) 100%);
|
||||
color: var(--color-brand-gold-deep);
|
||||
font-size: 22rpx;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 38rpx;
|
||||
border: 1px solid rgba(237, 189, 0, 0.18);
|
||||
}
|
||||
|
||||
.selector-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selector-card__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selector-card__value {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.selector-card__meta {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.selector-card__action {
|
||||
min-width: 96rpx;
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.picker-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 160;
|
||||
}
|
||||
|
||||
.picker-sheet__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 20, 18, 0.38);
|
||||
}
|
||||
|
||||
.picker-sheet__panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -12rpx 36rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.picker-sheet__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.picker-sheet__title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.picker-sheet__desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.picker-sheet__close {
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.picker-sheet__list {
|
||||
max-height: 58vh;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.picker-sheet__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 6rpx;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.picker-sheet__option--selected {
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx 18rpx;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(237, 189, 0, 0.22);
|
||||
box-shadow: 0 10rpx 24rpx rgba(237, 189, 0, 0.08);
|
||||
}
|
||||
|
||||
.picker-sheet__option-title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.picker-sheet__option-action {
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.picker-sheet__empty {
|
||||
padding: 48rpx 0 24rpx;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
176
user-app/src/pages/appraisal/service.vue
Normal file
176
user-app/src/pages/appraisal/service.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appraisalApi } from "../../api/appraisal";
|
||||
import FlowStepHeader from "../../components/FlowStepHeader.vue";
|
||||
import { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { showErrorToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const providerMeta = {
|
||||
anxinyan: {
|
||||
title: "实物鉴定",
|
||||
tag: "标准服务",
|
||||
desc: "由安心验提供标准实物鉴定服务,适合正式结果交付场景。",
|
||||
},
|
||||
zhongjian: {
|
||||
title: "中检鉴定",
|
||||
tag: "更高规格机构",
|
||||
desc: "由更高规格机构提供实物鉴定服务,流程一致,出具机构不同。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const providerCode = ref<keyof typeof providerMeta>("anxinyan");
|
||||
const store = useAppraisalStore();
|
||||
const submitting = ref(false);
|
||||
const loadError = ref("");
|
||||
|
||||
onLoad((options) => {
|
||||
const current = options?.provider as keyof typeof providerMeta | undefined;
|
||||
store.resetForNewFlow();
|
||||
store.clearLegacyExtraDefaults();
|
||||
if (current && providerMeta[current]) {
|
||||
providerCode.value = current;
|
||||
}
|
||||
store.setServiceProvider(providerCode.value);
|
||||
});
|
||||
|
||||
const provider = computed(() => providerCode.value);
|
||||
|
||||
async function ensureValidDraft() {
|
||||
store.setServiceProvider(provider.value);
|
||||
|
||||
if (!store.draftId) {
|
||||
const draft = await appraisalApi.createDraft(provider.value);
|
||||
store.setDraft(draft.draft_id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await appraisalApi.saveDraft({
|
||||
draft_id: store.draftId,
|
||||
current_step: 1,
|
||||
service_provider: provider.value,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
if (!message.includes("草稿不存在")) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
store.resetForNewFlow();
|
||||
store.setServiceProvider(provider.value);
|
||||
const draft = await appraisalApi.createDraft(provider.value);
|
||||
store.setDraft(draft.draft_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await withLoading("正在创建草稿", async () => {
|
||||
await ensureValidDraft();
|
||||
});
|
||||
loadError.value = "";
|
||||
store.setCurrentStep(1);
|
||||
uni.navigateTo({ url: "/pages/appraisal/product" });
|
||||
} catch (error) {
|
||||
loadError.value = "服务草稿创建失败,请检查本地接口服务或稍后重试。";
|
||||
showErrorToast(error, "创建草稿失败,请稍后重试");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
function chooseProvider(code: keyof typeof providerMeta) {
|
||||
providerCode.value = code;
|
||||
store.setServiceProvider(code);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<FlowStepHeader
|
||||
:step="1"
|
||||
title="选择鉴定服务"
|
||||
desc="对比安心验与中检两种服务方式,确认本次送检适合的出具机构和结果交付标准。"
|
||||
:chips="['服务流程一致', '机构与报告不同', '价格时效有差异']"
|
||||
/>
|
||||
|
||||
<view v-if="loadError" class="notice-card">
|
||||
<view class="notice-card__title">当前无法继续下一步</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
</view>
|
||||
|
||||
<view class="section selection-list">
|
||||
<view
|
||||
:class="['selection-card', provider === 'anxinyan' ? 'selection-card--selected' : '']"
|
||||
@click="chooseProvider('anxinyan')"
|
||||
>
|
||||
<view v-if="provider === 'anxinyan'" class="selection-card__selected-badge">已选服务</view>
|
||||
<text class="service-card__tag">标准服务</text>
|
||||
<view class="selection-card__title">实物鉴定</view>
|
||||
<view class="selection-card__desc">由安心验鉴定中心提供标准实物鉴定服务,适合正式结果交付场景。</view>
|
||||
<view class="service-card__footer">
|
||||
<text>预计 48 小时内出结果</text>
|
||||
<text>¥99 起</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
:class="['selection-card', provider === 'zhongjian' ? 'selection-card--selected' : '']"
|
||||
@click="chooseProvider('zhongjian')"
|
||||
>
|
||||
<view v-if="provider === 'zhongjian'" class="selection-card__selected-badge">已选服务</view>
|
||||
<text class="service-card__tag">更高规格机构</text>
|
||||
<view class="selection-card__title">中检鉴定</view>
|
||||
<view class="selection-card__desc">由更高规格鉴定机构提供实物鉴定服务,流程一致,出具机构与结果标准不同。</view>
|
||||
<view class="service-card__footer">
|
||||
<text>价格与时效依机构标准为准</text>
|
||||
<text>¥199 起</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">对比说明</view>
|
||||
<view class="info-list__row">
|
||||
<text class="info-list__label">鉴定流程</text>
|
||||
<text class="info-list__value">保持一致</text>
|
||||
</view>
|
||||
<view class="info-list__row">
|
||||
<text class="info-list__label">鉴定机构</text>
|
||||
<text class="info-list__value">安心验 / 中检机构</text>
|
||||
</view>
|
||||
<view class="info-list__row">
|
||||
<text class="info-list__label">报告出具方</text>
|
||||
<text class="info-list__value">随服务机构变化</text>
|
||||
</view>
|
||||
<view class="info-list__row">
|
||||
<text class="info-list__label">价格与时效</text>
|
||||
<text class="info-list__value">中检鉴定相对更高</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">已选服务</view>
|
||||
<view class="section__desc">{{ providerMeta[provider].title }} · {{ providerMeta[provider].desc }}</view>
|
||||
<view class="chip-list" style="margin-top: 20rpx;">
|
||||
<text class="choice-chip choice-chip--selected">{{ providerMeta[provider].tag }}</text>
|
||||
<text class="choice-chip">流程一致</text>
|
||||
<text class="choice-chip">凭证可验真</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">返回</view>
|
||||
<view :class="['btn', 'btn--primary', submitting ? 'btn--disabled' : '']" @click="goNext">
|
||||
{{ submitting ? "提交中..." : "下一步" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
130
user-app/src/pages/appraisal/success.vue
Normal file
130
user-app/src/pages/appraisal/success.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
|
||||
const orderNo = ref("AXY202604200001");
|
||||
const appraisalNo = ref("AXY-APP-20260420-0001");
|
||||
const orderId = ref(1);
|
||||
|
||||
function goOrder() {
|
||||
uni.navigateTo({ url: `/pages/order/detail?id=${orderId.value}` });
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.switchTab({ url: "/pages/home/index" });
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
orderNo.value = String(options?.order_no || orderNo.value);
|
||||
appraisalNo.value = String(options?.appraisal_no || appraisalNo.value);
|
||||
orderId.value = Number(options?.order_id || orderId.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view class="success-panel">
|
||||
<view class="success-panel__icon">✓</view>
|
||||
<view class="success-panel__title">订单提交成功</view>
|
||||
<view class="success-panel__desc">
|
||||
您的鉴定单已生成,后续可在订单详情中查看状态更新、补图提醒与正式报告。
|
||||
</view>
|
||||
<view class="certificate-header__meta" style="margin-top: 24rpx">
|
||||
<text class="certificate-meta-chip">订单号 {{ orderNo }}</text>
|
||||
<text class="certificate-meta-chip">鉴定单号 {{ appraisalNo }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">当前订单已进入履约流程</view>
|
||||
<view class="section__desc">这不是完成页,而是“服务已经开始”的提示页。接下来寄送、状态提醒和报告出具都会继续同步给您。</view>
|
||||
<view class="chip-list" style="margin-top: 18rpx;">
|
||||
<text class="choice-chip choice-chip--selected">订单已生成</text>
|
||||
<text class="choice-chip">后续状态实时同步</text>
|
||||
<text class="choice-chip">报告出具后可验真</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section summary-card">
|
||||
<view class="summary-card__title">下一步说明</view>
|
||||
<view class="success-steps">
|
||||
<view class="success-step">
|
||||
<view class="success-step__index">1</view>
|
||||
<view class="success-step__body">
|
||||
<view class="success-step__title">尽快寄送商品</view>
|
||||
<view class="success-step__desc">按订单提示将商品寄送至鉴定中心,越早寄出越能更快进入作业流程。</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="success-step">
|
||||
<view class="success-step__index">2</view>
|
||||
<view class="success-step__body">
|
||||
<view class="success-step__title">关注状态提醒</view>
|
||||
<view class="success-step__desc">订单状态更新、补图提醒和关键节点通知都会第一时间同步给您。</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="success-step">
|
||||
<view class="success-step__index">3</view>
|
||||
<view class="success-step__body">
|
||||
<view class="success-step__title">查看报告并验真</view>
|
||||
<view class="success-step__desc">正式报告出具后,可直接在报告中心查看结果并继续完成验真。</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goHome">返回首页</view>
|
||||
<view class="btn btn--primary" @click="goOrder">查看订单</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.success-steps {
|
||||
display: grid;
|
||||
gap: 18rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.success-step {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: flex-start;
|
||||
padding: 22rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
border: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.success-step__index {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 16rpx;
|
||||
background: linear-gradient(145deg, #edbd00 0%, #c89b00 100%);
|
||||
color: #1d1d1d;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success-step__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.success-step__title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.success-step__desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
370
user-app/src/pages/appraisal/upload.vue
Normal file
370
user-app/src/pages/appraisal/upload.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appraisalApi } from "../../api/appraisal";
|
||||
import FlowStepHeader from "../../components/FlowStepHeader.vue";
|
||||
import { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const store = useAppraisalStore();
|
||||
const pageLoading = ref(true);
|
||||
const loadError = ref("");
|
||||
const saving = ref(false);
|
||||
const uploadingCode = ref("");
|
||||
const emptyTemplate = computed(
|
||||
() =>
|
||||
!pageLoading.value &&
|
||||
!loadError.value &&
|
||||
store.uploadTemplateId <= 0 &&
|
||||
store.requiredItems.length === 0 &&
|
||||
store.optionalItems.length === 0,
|
||||
);
|
||||
|
||||
const completedRequiredCount = computed(
|
||||
() => store.requiredItems.filter((item) => (item.files?.length || 0) > 0).length,
|
||||
);
|
||||
|
||||
const canSkipUpload = computed(() => emptyTemplate.value);
|
||||
|
||||
function openHelpCenter(keyword = "") {
|
||||
const suffix = keyword ? `?q=${encodeURIComponent(keyword)}` : "";
|
||||
uni.navigateTo({ url: `/pages/help/index${suffix}` });
|
||||
}
|
||||
|
||||
function contactSupport() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/support/create?ticket_type=upload_issue&prefill_title=${encodeURIComponent("上传资料协助")}`,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTag(status: string) {
|
||||
if (status === "已完成") return "tag tag--success";
|
||||
if (status === "建议优化") return "tag tag--warning";
|
||||
return "tag tag--neutral";
|
||||
}
|
||||
|
||||
const requiredItems = computed(() =>
|
||||
store.requiredItems.map((item) => ({
|
||||
title: item.item_name,
|
||||
desc: item.guide_text,
|
||||
status:
|
||||
(item.files?.length || 0) > 0
|
||||
? "已完成"
|
||||
: item.quality_status === "suggested"
|
||||
? "建议优化"
|
||||
: "未上传",
|
||||
itemCode: item.item_code,
|
||||
fileCount: item.files?.length || 0,
|
||||
files: item.files || [],
|
||||
})),
|
||||
);
|
||||
|
||||
const optionalItems = computed(() =>
|
||||
store.optionalItems.map((item) => ({
|
||||
title: item.item_name,
|
||||
desc: item.guide_text,
|
||||
itemCode: item.item_code,
|
||||
fileCount: item.files?.length || 0,
|
||||
files: item.files || [],
|
||||
})),
|
||||
);
|
||||
|
||||
async function goNext() {
|
||||
const missingRequired = store.requiredItems.some((item) => !item.files || item.files.length === 0);
|
||||
if (missingRequired) {
|
||||
showInfoToast("请先完成所有必传资料上传");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
saving.value = true;
|
||||
await withLoading("正在保存上传资料", async () => {
|
||||
const payload = {
|
||||
draft_id: store.draftId,
|
||||
current_step: 4,
|
||||
upload_info: {
|
||||
template_id: store.uploadTemplateId,
|
||||
items: [
|
||||
...store.requiredItems.map((item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
is_required: true,
|
||||
quality_status: item.files?.length ? "uploaded" : "pending",
|
||||
quality_message: "",
|
||||
files: item.files || [],
|
||||
})),
|
||||
...store.optionalItems.map((item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
is_required: false,
|
||||
quality_status: item.files?.length ? "uploaded" : "optional",
|
||||
quality_message: "",
|
||||
files: item.files || [],
|
||||
})),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await appraisalApi.saveDraft(payload);
|
||||
} catch (error) {
|
||||
if (!isMissingDraftError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await rebuildDraftFromStore(store);
|
||||
await appraisalApi.saveDraft({
|
||||
...payload,
|
||||
draft_id: store.draftId,
|
||||
});
|
||||
}
|
||||
});
|
||||
store.setCurrentStep(4);
|
||||
uni.navigateTo({ url: "/pages/appraisal/confirm" });
|
||||
} catch (error) {
|
||||
showErrorToast(error, "上传资料保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
async function chooseAndUpload(itemCode: string, itemName: string, isRequired: boolean) {
|
||||
try {
|
||||
const chooseResult = await uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
const filePath = chooseResult.tempFilePaths?.[0];
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingCode.value = itemCode;
|
||||
const file = await appraisalApi.uploadFile({
|
||||
draftId: store.draftId,
|
||||
itemCode,
|
||||
itemName,
|
||||
filePath,
|
||||
});
|
||||
store.upsertUploadFile(itemCode, file, isRequired);
|
||||
showInfoToast("图片上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "图片上传失败");
|
||||
} finally {
|
||||
uploadingCode.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function previewFiles(files: Array<{ file_url: string }>, current: string) {
|
||||
if (!files.length) return;
|
||||
uni.previewImage({
|
||||
urls: files.map((item) => item.file_url),
|
||||
current,
|
||||
});
|
||||
}
|
||||
|
||||
async function removeFile(itemCode: string, fileId: string, fileUrl: string, isRequired: boolean) {
|
||||
try {
|
||||
await appraisalApi.deleteFile(fileUrl);
|
||||
store.removeUploadFile(itemCode, fileId, isRequired);
|
||||
showInfoToast("已删除上传文件");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "删除文件失败");
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async () => {
|
||||
store.hydrate();
|
||||
pageLoading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
if (!store.draftId) {
|
||||
await rebuildDraftFromStore(store);
|
||||
}
|
||||
const data = await appraisalApi.getUploadTemplate(store.product.categoryId, store.serviceProvider);
|
||||
store.setUploadTemplate(data.template_id, data.required_items, data.optional_items);
|
||||
if (store.draftId) {
|
||||
const draft = await appraisalApi.getDraft(store.draftId);
|
||||
const items = draft.upload_info?.items || [];
|
||||
if (items.length) {
|
||||
store.hydrateUploadItems(items);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingDraftError(error)) {
|
||||
try {
|
||||
await rebuildDraftFromStore(store);
|
||||
const data = await appraisalApi.getUploadTemplate(store.product.categoryId, store.serviceProvider);
|
||||
store.setUploadTemplate(data.template_id, data.required_items, data.optional_items);
|
||||
showInfoToast("草稿已自动恢复,可继续上传资料。");
|
||||
} catch (recoverError) {
|
||||
store.resetForNewFlow();
|
||||
loadError.value = "上传模板加载失败,请重新选择商品信息后再试。";
|
||||
showErrorToast(recoverError, "上传模板加载失败");
|
||||
}
|
||||
} else {
|
||||
loadError.value = "上传模板加载失败,请稍后重试。";
|
||||
showErrorToast(error, "上传模板加载失败");
|
||||
}
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<FlowStepHeader
|
||||
:step="4"
|
||||
title="上传鉴定资料"
|
||||
desc="按模板上传清晰图片与辅助凭证,资料越完整,越有利于提升鉴定效率与准确度。"
|
||||
:chips="['按模板上传', '支持补充凭证', '资料越全越准确']"
|
||||
/>
|
||||
|
||||
<view class="section progress-panel">
|
||||
<view class="progress-panel__top">
|
||||
<view class="section__title">当前完成进度</view>
|
||||
<view class="progress-panel__value">
|
||||
{{ completedRequiredCount }} / {{ store.requiredItems.length }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress-panel__hint">
|
||||
{{ canSkipUpload ? "当前品类未配置上传模板,无需上传附件,可直接进入下一步。" : `已完成 ${completedRequiredCount} 项必传资料,全部完成后即可进入下一步。` }}
|
||||
</view>
|
||||
<view class="progress-panel__bar">
|
||||
<view
|
||||
class="progress-panel__fill"
|
||||
:style="{
|
||||
width: `${store.requiredItems.length ? Math.max(12, (completedRequiredCount / store.requiredItems.length) * 100) : 0}%`,
|
||||
}"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">资料上传建议</view>
|
||||
<view class="section__desc">先完成必传资料,再补充选传资料,整页会更像任务清单,也更方便用户判断下一步。</view>
|
||||
<view class="chip-list" style="margin-top: 18rpx;">
|
||||
<text class="choice-chip choice-chip--selected">{{ completedRequiredCount }} 项已完成</text>
|
||||
<text class="choice-chip">{{ store.optionalItems.length }} 项选传资料</text>
|
||||
<text class="choice-chip">支持回看与删除</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="pageLoading" class="notice-card">
|
||||
<view class="notice-card__title">正在加载上传模板</view>
|
||||
<view class="notice-card__desc">资料任务项加载完成后,可继续上传并进入下一步。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loadError" class="notice-card">
|
||||
<view class="notice-card__title">上传模板异常</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="emptyTemplate" class="notice-card">
|
||||
<view class="notice-card__title">当前品类暂未配置上传模板</view>
|
||||
<view class="notice-card__desc">当前无需上传任何附件,可直接进入下一步确认并提交鉴定。</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section__heading">
|
||||
<view>
|
||||
<view class="section__title">必传资料</view>
|
||||
<view class="section__desc">以下资料将用于正式鉴定,请按要求上传。</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-for="item in requiredItems" :key="item.itemCode" class="task-card section-card">
|
||||
<view class="task-card__row">
|
||||
<view class="task-card__title">{{ item.title }}</view>
|
||||
<text :class="resolveTag(item.status)">{{ item.status }}</text>
|
||||
</view>
|
||||
<view class="task-card__desc">{{ item.desc }}</view>
|
||||
<view class="task-card__row" style="margin-top: 12rpx">
|
||||
<text class="info-list__label">已上传</text>
|
||||
<text class="info-list__value">{{ item.fileCount }} 张</text>
|
||||
</view>
|
||||
<view v-if="item.files.length" class="task-files">
|
||||
<view v-for="file in item.files" :key="file.file_id" class="task-file">
|
||||
<image
|
||||
class="task-file__img"
|
||||
:src="file.thumbnail_url"
|
||||
mode="aspectFill"
|
||||
@click="previewFiles(item.files, file.file_url)"
|
||||
/>
|
||||
<view class="task-file__remove" @click="removeFile(item.itemCode, file.file_id, file.file_url, true)">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-card__row" style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="chooseAndUpload(item.itemCode, item.title, true)">
|
||||
{{ uploadingCode === item.itemCode ? "上传中..." : "上传 / 重拍" }}
|
||||
</text>
|
||||
<text class="btn btn--ghost" @click="openHelpCenter(item.title)">拍摄说明</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section__heading">
|
||||
<view>
|
||||
<view class="section__title">选传资料</view>
|
||||
<view class="section__desc">以下资料非必传,上传后可帮助提升判断准确度。</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-for="item in optionalItems" :key="item.itemCode" class="task-card section-card">
|
||||
<view class="task-card__row">
|
||||
<view class="task-card__title">{{ item.title }}</view>
|
||||
<text class="tag tag--neutral">选传</text>
|
||||
</view>
|
||||
<view class="task-card__desc">{{ item.desc }}</view>
|
||||
<view class="task-card__row" style="margin-top: 12rpx">
|
||||
<text class="info-list__label">已上传</text>
|
||||
<text class="info-list__value">{{ item.fileCount }} 张</text>
|
||||
</view>
|
||||
<view v-if="item.files.length" class="task-files">
|
||||
<view v-for="file in item.files" :key="file.file_id" class="task-file">
|
||||
<image
|
||||
class="task-file__img"
|
||||
:src="file.thumbnail_url"
|
||||
mode="aspectFill"
|
||||
@click="previewFiles(item.files, file.file_url)"
|
||||
/>
|
||||
<view class="task-file__remove" @click="removeFile(item.itemCode, file.file_id, file.file_url, false)">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-card__row" style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="chooseAndUpload(item.itemCode, item.title, false)">
|
||||
{{ uploadingCode === item.itemCode ? "上传中..." : "上传资料" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">拍摄帮助</view>
|
||||
<view class="info-list__row" @click="openHelpCenter('拍摄示例')">
|
||||
<text class="info-list__label">查看拍摄示例</text>
|
||||
<text class="info-list__value">了解标准拍摄方式</text>
|
||||
</view>
|
||||
<view class="info-list__row" @click="openHelpCenter('拍摄问题')">
|
||||
<text class="info-list__label">常见拍摄问题</text>
|
||||
<text class="info-list__value">避免模糊、反光、遮挡</text>
|
||||
</view>
|
||||
<view class="info-list__row" @click="contactSupport">
|
||||
<text class="info-list__label">联系客服协助</text>
|
||||
<text class="info-list__value">遇到问题可随时咨询</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">返回</view>
|
||||
<view :class="['btn', 'btn--primary', (pageLoading || saving) ? 'btn--disabled' : '']" @click="goNext">
|
||||
{{ saving ? "保存中..." : "下一步" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
543
user-app/src/pages/auth/login.vue
Normal file
543
user-app/src/pages/auth/login.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { authApi } from "../../api/auth";
|
||||
import { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
import { isLoggedIn, isWechatBrowser, navigateAfterLogin, setUserToken } from "../../utils/auth";
|
||||
|
||||
type LoginMode = "code" | "password";
|
||||
const COUNTDOWN_STORAGE_KEY = "anxinyan_login_code_countdown_expire_at";
|
||||
|
||||
const mode = ref<LoginMode>("code");
|
||||
const sending = ref(false);
|
||||
const submitting = ref(false);
|
||||
const countdown = ref(0);
|
||||
const redirect = ref("");
|
||||
const sendCodeErrorMessage = ref("");
|
||||
const appraisalStore = useAppraisalStore();
|
||||
|
||||
const form = reactive({
|
||||
mobile: "",
|
||||
code: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const browserHint = computed(() =>
|
||||
isWechatBrowser()
|
||||
? "微信内也支持手机号快捷登录,后续可继续补充微信授权。"
|
||||
: "当前为非微信浏览器环境,可直接使用手机号验证码或密码登录。",
|
||||
);
|
||||
|
||||
const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}s 后重发` : "发送验证码"));
|
||||
const countdownHint = computed(() =>
|
||||
countdown.value > 0 ? `${countdown.value} 秒后可重新发送验证码` : "验证码有效期 5 分钟,请注意查收短信。",
|
||||
);
|
||||
|
||||
function resolveSendCodeError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
|
||||
if (message.includes("触发号码天级流控")) {
|
||||
return "该手机号今日获取验证码次数已达上限,请明天再试或更换手机号。";
|
||||
}
|
||||
|
||||
if (message.includes("请") && message.includes("秒后再试")) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (message.includes("cURL error 28") || message.includes("Resolving timed out")) {
|
||||
return "短信服务连接超时,请稍后重试。如多次失败,请联系管理员检查服务器网络。";
|
||||
}
|
||||
|
||||
if (message.includes("cURL error 60") || message.includes("SSL certificate")) {
|
||||
return "短信服务证书校验失败,请联系管理员检查服务器环境与 CA 证书配置。";
|
||||
}
|
||||
|
||||
if (message.includes("短信配置未完成")) {
|
||||
return "短信发送配置尚未完成,请联系管理员在后台补全阿里云短信参数。";
|
||||
}
|
||||
|
||||
return message || "验证码发送失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
uni.removeStorageSync(COUNTDOWN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function startCountdown(seconds = 60) {
|
||||
startCountdownByExpireAt(Date.now() + seconds * 1000);
|
||||
}
|
||||
|
||||
function startCountdownByExpireAt(expireAt: number) {
|
||||
if (!Number.isFinite(expireAt)) {
|
||||
clearCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (expireAt <= Date.now()) {
|
||||
clearCountdown();
|
||||
countdown.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
|
||||
uni.setStorageSync(COUNTDOWN_STORAGE_KEY, String(expireAt));
|
||||
const updateCountdown = () => {
|
||||
const left = Math.max(0, Math.ceil((expireAt - Date.now()) / 1000));
|
||||
countdown.value = left;
|
||||
if (left <= 0) {
|
||||
clearCountdown();
|
||||
countdown.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
countdownTimer = setInterval(() => {
|
||||
updateCountdown();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function restoreCountdown() {
|
||||
const raw = String(uni.getStorageSync(COUNTDOWN_STORAGE_KEY) || "");
|
||||
const expireAt = Number(raw);
|
||||
if (!expireAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
startCountdownByExpireAt(expireAt);
|
||||
}
|
||||
|
||||
function validateMobile() {
|
||||
if (!/^1\d{10}$/.test(form.mobile.trim())) {
|
||||
showInfoToast("请输入正确的手机号");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.reLaunch({ url: "/pages/home/index" });
|
||||
}
|
||||
|
||||
async function handleSendCode() {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (!validateMobile()) return;
|
||||
|
||||
sendCodeErrorMessage.value = "";
|
||||
sending.value = true;
|
||||
try {
|
||||
const data = await withLoading("正在发送验证码", async () => authApi.sendLoginCode(form.mobile.trim()));
|
||||
startCountdown(data.retry_after_seconds || 60);
|
||||
if (data.debug_code) {
|
||||
showInfoToast(`调试验证码:${data.debug_code}`);
|
||||
} else {
|
||||
showInfoToast("验证码已发送");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
const retryMatch = message.match(/(\d+)\s*秒后再试/);
|
||||
if (retryMatch) {
|
||||
startCountdown(Number(retryMatch[1]));
|
||||
}
|
||||
sendCodeErrorMessage.value = resolveSendCodeError(error);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return;
|
||||
if (!validateMobile()) return;
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result = await withLoading("正在登录", async () => {
|
||||
if (mode.value === "code") {
|
||||
if (!/^\d{6}$/.test(form.code.trim())) {
|
||||
throw new Error("请输入 6 位验证码");
|
||||
}
|
||||
return authApi.loginByCode(form.mobile.trim(), form.code.trim());
|
||||
}
|
||||
|
||||
if (!form.password.trim()) {
|
||||
throw new Error("请输入登录密码");
|
||||
}
|
||||
return authApi.loginByPassword(form.mobile.trim(), form.password.trim());
|
||||
});
|
||||
|
||||
setUserToken(result.token);
|
||||
appraisalStore.resetForNewFlow();
|
||||
showInfoToast("登录成功");
|
||||
navigateAfterLogin(redirect.value || "/pages/mine/index");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "登录失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
redirect.value = String(options?.redirect || "");
|
||||
restoreCountdown();
|
||||
if (isLoggedIn()) {
|
||||
navigateAfterLogin(redirect.value || "/pages/mine/index");
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => form.mobile,
|
||||
() => {
|
||||
sendCodeErrorMessage.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
watch(mode, () => {
|
||||
sendCodeErrorMessage.value = "";
|
||||
});
|
||||
|
||||
onUnmounted(clearCountdown);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="auth-page">
|
||||
<view class="auth-backdrop auth-backdrop--gold"></view>
|
||||
<view class="auth-backdrop auth-backdrop--mist"></view>
|
||||
|
||||
<view class="auth-shell">
|
||||
<view class="auth-hero">
|
||||
<view class="auth-brand-row">
|
||||
<view class="auth-brand-mark">安</view>
|
||||
<view>
|
||||
<view class="auth-brand-title">安心验</view>
|
||||
<view class="auth-brand-subtitle">独立第三方奢侈品鉴定平台</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="auth-title">手机号登录</view>
|
||||
<view class="auth-desc">{{ browserHint }}</view>
|
||||
|
||||
<view class="auth-feature-list">
|
||||
<view class="auth-feature">订单进度自动同步</view>
|
||||
<view class="auth-feature">报告与验真统一管理</view>
|
||||
<view class="auth-feature">工单、地址、消息集中查看</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="auth-panel">
|
||||
<view class="auth-switch">
|
||||
<view :class="['auth-switch__item', mode === 'code' ? 'auth-switch__item--active' : '']" @click="mode = 'code'">
|
||||
验证码登录
|
||||
</view>
|
||||
<view :class="['auth-switch__item', mode === 'password' ? 'auth-switch__item--active' : '']" @click="mode = 'password'">
|
||||
账号密码登录
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="auth-form">
|
||||
<view class="auth-field">
|
||||
<view class="auth-field__label">手机号</view>
|
||||
<view class="auth-input-wrap">
|
||||
<input v-model="form.mobile" class="auth-input" maxlength="11" type="number" placeholder="请输入登录手机号" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="mode === 'code'" class="auth-field">
|
||||
<view class="auth-field__label">验证码</view>
|
||||
<view class="auth-code-row">
|
||||
<view class="auth-input-wrap auth-code-row__input">
|
||||
<input v-model="form.code" class="auth-input" maxlength="6" type="number" placeholder="请输入 6 位验证码" />
|
||||
</view>
|
||||
<view :class="['auth-code-btn', countdown > 0 ? 'auth-code-btn--disabled' : '']" @click="handleSendCode">
|
||||
{{ sending ? "发送中..." : sendButtonText }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="sendCodeErrorMessage" class="auth-error-banner">
|
||||
{{ sendCodeErrorMessage }}
|
||||
</view>
|
||||
<view class="auth-field__hint auth-field__hint--countdown">{{ countdownHint }}</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="auth-field">
|
||||
<view class="auth-field__label">登录密码</view>
|
||||
<view class="auth-input-wrap">
|
||||
<input v-model="form.password" class="auth-input" password placeholder="请输入登录密码" />
|
||||
</view>
|
||||
<view class="auth-field__hint">如果当前账号还未设置密码,请先使用验证码登录后,到“设置 - 登录与安全”中设置密码。</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="auth-note">
|
||||
登录后即可查看订单、报告、消息、地址和售后工单;扫码打开的公开报告页与验真页无需登录。
|
||||
</view>
|
||||
|
||||
<view class="auth-actions">
|
||||
<view class="btn btn--secondary auth-actions__button" @click="goHome">返回首页</view>
|
||||
<view :class="['btn', 'btn--primary', 'auth-actions__button', submitting ? 'btn--disabled' : '']" @click="handleSubmit">
|
||||
{{ submitting ? "登录中..." : "立即登录" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auth-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: 36rpx 28rpx 48rpx;
|
||||
background: #f2f2f4;
|
||||
}
|
||||
|
||||
.auth-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-backdrop--gold {
|
||||
top: -120rpx;
|
||||
right: -60rpx;
|
||||
width: 360rpx;
|
||||
height: 360rpx;
|
||||
background: radial-gradient(circle, rgba(237, 189, 0, 0.28), transparent 68%);
|
||||
}
|
||||
|
||||
.auth-backdrop--mist {
|
||||
left: -80rpx;
|
||||
bottom: 140rpx;
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
background: radial-gradient(circle, rgba(21, 21, 21, 0.08), transparent 70%);
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 28rpx;
|
||||
}
|
||||
|
||||
.auth-hero {
|
||||
padding: 36rpx 34rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
color: #252527;
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.auth-brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.auth-brand-mark {
|
||||
width: 78rpx;
|
||||
height: 78rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #edbd00;
|
||||
color: #ffffff;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
line-height: 78rpx;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.auth-brand-title {
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.auth-brand-subtitle {
|
||||
margin-top: 8rpx;
|
||||
color: #777;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
margin-top: 30rpx;
|
||||
font-size: 52rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.auth-desc {
|
||||
margin-top: 16rpx;
|
||||
color: #666;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.auth-feature-list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.auth-feature {
|
||||
position: relative;
|
||||
padding-left: 26rpx;
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.auth-feature::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 14rpx;
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: #edbd00;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
padding: 30rpx 28rpx;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14rpx;
|
||||
padding: 10rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f4f4f6;
|
||||
}
|
||||
|
||||
.auth-switch__item {
|
||||
min-height: 82rpx;
|
||||
border-radius: 22rpx;
|
||||
color: #666;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
line-height: 82rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-switch__item--active {
|
||||
background: #252527;
|
||||
color: #ffffff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
gap: 24rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.auth-field__label {
|
||||
margin-bottom: 12rpx;
|
||||
color: #252527;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-field__hint {
|
||||
margin-top: 12rpx;
|
||||
color: #777;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.auth-field__hint--countdown {
|
||||
color: #c89b00;
|
||||
}
|
||||
|
||||
.auth-error-banner {
|
||||
margin-top: 14rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(159, 59, 50, 0.08);
|
||||
border: 1px solid rgba(159, 59, 50, 0.16);
|
||||
color: #9f3b32;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.auth-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 92rpx;
|
||||
padding: 0 24rpx;
|
||||
border: 1px solid #ededf0;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
color: #252527;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.auth-code-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 212rpx;
|
||||
gap: 14rpx;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-code-row__input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-code-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 92rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(237, 189, 0, 0.12);
|
||||
color: #c89b00;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(237, 189, 0, 0.24);
|
||||
}
|
||||
|
||||
.auth-code-btn--disabled {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.auth-note {
|
||||
margin-top: 26rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f7f7f8;
|
||||
color: #666;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.8;
|
||||
border: 1px solid rgba(228, 219, 200, 0.72);
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.auth-actions__button {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
120
user-app/src/pages/help/detail.vue
Normal file
120
user-app/src/pages/help/detail.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appApi, type HelpArticleDetailData } from "../../api/app";
|
||||
import { helpArticleDetailFallback } from "../../mocks/app";
|
||||
import { resolveErrorMessage } from "../../utils/feedback";
|
||||
|
||||
const detail = ref<HelpArticleDetailData>(helpArticleDetailFallback);
|
||||
const articleId = ref(0);
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
|
||||
function openArticle(id: number) {
|
||||
uni.redirectTo({ url: `/pages/help/detail?id=${id}` });
|
||||
}
|
||||
|
||||
function contactSupport() {
|
||||
uni.navigateTo({ url: "/pages/support/index" });
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
articleId.value = Number(options?.id || 0);
|
||||
if (!articleId.value) {
|
||||
loadError.value = "缺少文章编号,无法查看内容。";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await appApi.getHelpArticleDetail(articleId.value);
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("help article fallback", error);
|
||||
loadError.value = resolveErrorMessage(error, "帮助文章加载失败,请稍后重试。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载文章</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步文章正文与相关问题。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">帮助文章加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="section-card section-card--soft">
|
||||
<view style="display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap">
|
||||
<text class="tag tag--accent">{{ detail.article.category_text }}</text>
|
||||
<text v-if="detail.article.is_recommended" class="tag tag--warning">推荐阅读</text>
|
||||
<text class="certificate-meta-chip">{{ detail.article.updated_at }}</text>
|
||||
</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
{{ detail.article.title }}
|
||||
</view>
|
||||
<view class="section__desc">{{ detail.article.summary }}</view>
|
||||
<view class="chip-list">
|
||||
<view v-for="item in detail.article.keywords" :key="item" class="choice-chip">
|
||||
{{ item }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">详细说明</view>
|
||||
<view
|
||||
v-for="(item, index) in detail.article.content_blocks"
|
||||
:key="`${detail.article.id}-${index}`"
|
||||
class="report-meta__row report-meta__row--stacked"
|
||||
>
|
||||
<text class="report-meta__label">{{ index + 1 }}.</text>
|
||||
<text class="report-meta__value">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.related_articles.length" class="section">
|
||||
<view class="section__heading">
|
||||
<view>
|
||||
<view class="section__title">相关问题</view>
|
||||
<view class="section__desc">如果这个问题还没完全解决,可以继续看这些相关文章。</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in detail.related_articles"
|
||||
:key="item.id"
|
||||
class="section message-card"
|
||||
@click="openArticle(item.id)"
|
||||
>
|
||||
<view class="message-card__top">
|
||||
<view style="flex: 1">
|
||||
<view style="display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap">
|
||||
<text class="message-card__title">{{ item.title }}</text>
|
||||
<text class="tag tag--neutral">{{ item.category_text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="message-card__time">{{ item.updated_at }}</text>
|
||||
</view>
|
||||
<view class="message-card__content">{{ item.summary }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-note">
|
||||
<view class="support-banner__title">还有疑问?</view>
|
||||
<view class="support-banner__desc">如果帮助文章仍不能解决您的问题,可以直接联系客服发起工单,我们会尽快跟进。</view>
|
||||
<view style="margin-top: 16rpx">
|
||||
<text class="btn btn--ghost" @click="contactSupport">去客服支持</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
188
user-app/src/pages/help/index.vue
Normal file
188
user-app/src/pages/help/index.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type HelpArticleSummary, type HelpCenterData } from "../../api/app";
|
||||
import { resolveErrorMessage } from "../../utils/feedback";
|
||||
|
||||
type HelpCategoryCode = HelpCenterData["categories"][number]["code"];
|
||||
|
||||
const categories = ref<HelpCenterData["categories"]>([]);
|
||||
const articles = ref<HelpArticleSummary[]>([]);
|
||||
const keyword = ref("");
|
||||
const currentCategory = ref<HelpCategoryCode>("all");
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
|
||||
const recommendedArticles = computed(() => articles.value.filter((item) => item.is_recommended).slice(0, 3));
|
||||
const currentCategoryInfo = computed(
|
||||
() => categories.value.find((item) => item.code === currentCategory.value) || categories.value[0] || null,
|
||||
);
|
||||
|
||||
async function fetchHelpCenter() {
|
||||
loading.value = true;
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
const data = await appApi.getHelpCenter({
|
||||
q: keyword.value || undefined,
|
||||
category: currentCategory.value === "all" ? undefined : currentCategory.value,
|
||||
});
|
||||
categories.value = data.categories;
|
||||
articles.value = data.articles;
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("help center fallback", error);
|
||||
loadError.value = !pageReady.value
|
||||
? resolveErrorMessage(error, "帮助内容加载失败,请稍后重试。")
|
||||
: loadError.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openArticle(id: number) {
|
||||
uni.navigateTo({ url: `/pages/help/detail?id=${id}` });
|
||||
}
|
||||
|
||||
function goSupport() {
|
||||
uni.navigateTo({ url: "/pages/support/index" });
|
||||
}
|
||||
|
||||
function selectCategory(category: HelpCategoryCode) {
|
||||
currentCategory.value = category;
|
||||
fetchHelpCenter();
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
fetchHelpCenter();
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
keyword.value = String(options?.q || "");
|
||||
});
|
||||
|
||||
onShow(fetchHelpCenter);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载帮助内容</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在整理当前分类与关键词下的文章。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">帮助内容加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="fetchHelpCenter">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="page-hero page-hero--layered">
|
||||
<view class="page-hero__eyebrow">帮助中心</view>
|
||||
<view class="page-title">常见问题与使用指引</view>
|
||||
<view class="page-subtitle">从下单、寄送到报告验真,把最常见的问题放在一个地方一次讲清楚。</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-panel__title">搜索问题</view>
|
||||
<view class="field-box" style="margin-top: 16rpx">
|
||||
<input v-model="keyword" class="field-input" confirm-type="search" placeholder="例如:中检鉴定、运单、验真、补资料" @confirm="submitSearch" />
|
||||
<text class="btn btn--ghost" @click="submitSearch">搜索</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">分类浏览</view>
|
||||
<view class="chip-list">
|
||||
<view
|
||||
v-for="item in categories"
|
||||
:key="item.code"
|
||||
:class="['choice-chip', currentCategory === item.code ? 'choice-chip--selected' : '']"
|
||||
@click="selectCategory(item.code)"
|
||||
>
|
||||
{{ item.title }} {{ item.count }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="currentCategoryInfo?.desc" class="section__desc" style="margin-top: 18rpx;">
|
||||
{{ currentCategoryInfo.desc }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载帮助内容</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在整理当前分类与关键词下的文章。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && recommendedArticles.length" class="section">
|
||||
<view class="section__heading">
|
||||
<view>
|
||||
<view class="section__title">推荐阅读</view>
|
||||
<view class="section__desc">先看这些高频问题,通常能最快解决当前疑问。</view>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in recommendedArticles"
|
||||
:key="`recommend-${item.id}`"
|
||||
class="section message-card"
|
||||
@click="openArticle(item.id)"
|
||||
>
|
||||
<view class="message-card__top">
|
||||
<view style="flex: 1">
|
||||
<view style="display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap">
|
||||
<text class="message-card__title">{{ item.title }}</text>
|
||||
<text class="tag tag--accent">推荐</text>
|
||||
<text class="tag tag--neutral">{{ item.category_text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="message-card__time">{{ item.updated_at }}</text>
|
||||
</view>
|
||||
<view class="message-card__content">{{ item.summary }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section__heading">
|
||||
<view>
|
||||
<view class="section__title">全部文章</view>
|
||||
<view class="section__desc">当前条件下共 {{ articles.length }} 篇文章,点击可查看完整说明。</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in articles"
|
||||
:key="item.id"
|
||||
class="section message-card"
|
||||
@click="openArticle(item.id)"
|
||||
>
|
||||
<view class="message-card__top">
|
||||
<view style="flex: 1">
|
||||
<view style="display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap">
|
||||
<text class="message-card__title">{{ item.title }}</text>
|
||||
<text class="tag tag--neutral">{{ item.category_text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="message-card__time">{{ item.updated_at }}</text>
|
||||
</view>
|
||||
<view class="message-card__content">{{ item.summary }}</view>
|
||||
<view class="message-card__footer">
|
||||
<text class="message-card__state">{{ item.keywords.join(" / ") }}</text>
|
||||
<text class="btn btn--ghost">查看详情</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && !articles.length" class="section section-card section-note">
|
||||
<view class="section__title">没有找到相关内容</view>
|
||||
<view class="section__desc">您可以换个关键词再试,或者直接前往客服支持发起工单。</view>
|
||||
<view style="margin-top: 16rpx">
|
||||
<text class="btn btn--ghost" @click="goSupport">去客服支持</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
732
user-app/src/pages/home/index.vue
Normal file
732
user-app/src/pages/home/index.vue
Normal file
@@ -0,0 +1,732 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type HomeData } from "../../api/app";
|
||||
import { homeFallback } from "../../mocks/app";
|
||||
|
||||
const heroBanner = ref(homeFallback.banners[0]);
|
||||
const serviceCards = ref<HomeData["service_entries"]>(homeFallback.service_entries);
|
||||
const categories = ref<HomeData["category_entries"]>(homeFallback.category_entries);
|
||||
const metrics = ref(homeFallback.trust_metrics);
|
||||
const pageLoading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const categoryDataLoaded = ref(false);
|
||||
const loadError = ref("");
|
||||
const defaultHeroBackground = "/static/home/home-reference.jpg";
|
||||
|
||||
const categoryFallbackVisuals = [
|
||||
{ visual: "bag", keys: ["luxury_bag", "奢侈品箱包", "箱包"] },
|
||||
{ visual: "shoes", keys: ["sneaker", "潮流鞋类", "鞋"] },
|
||||
{ visual: "jewelry", keys: ["jewelry", "首饰配饰", "首饰", "珠宝"] },
|
||||
{ visual: "beauty", keys: ["beauty", "高端美妆", "美妆"] },
|
||||
{ visual: "watch", keys: ["watch", "腕表", "手表"] },
|
||||
{ visual: "clothes", keys: ["clothing", "服饰", "服装"] },
|
||||
{ visual: "digital", keys: ["digital", "3c数码", "数码"] },
|
||||
{ visual: "antique", keys: ["antique", "curio", "古董文玩", "古董", "文玩"] },
|
||||
];
|
||||
|
||||
function normalizeCategoryToken(value = "") {
|
||||
return value.replace(/[\s\u200B-\u200D\uFEFF]+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function resolveCategoryVisual(item: HomeData["category_entries"][number]) {
|
||||
const code = normalizeCategoryToken(item.category_code);
|
||||
const name = normalizeCategoryToken(item.category_name);
|
||||
const matched = categoryFallbackVisuals.find((meta) =>
|
||||
meta.keys.some((key) => {
|
||||
const normalizedKey = normalizeCategoryToken(key);
|
||||
return code === normalizedKey || name === normalizedKey || (normalizedKey !== "" && name.includes(normalizedKey));
|
||||
}),
|
||||
);
|
||||
|
||||
return matched?.visual || "default";
|
||||
}
|
||||
|
||||
const homeMetrics = computed(() =>
|
||||
(metrics.value.length ? metrics.value : homeFallback.trust_metrics).slice(0, 3).map((item) => {
|
||||
const match = item.value.match(/^([\d,.]+)(.*)$/);
|
||||
return {
|
||||
label: item.label,
|
||||
value: match?.[1] || item.value,
|
||||
unit: match?.[2] || "",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const heroStyle = computed(() => ({
|
||||
backgroundImage: `url("${heroBanner.value.background_image_url || defaultHeroBackground}")`,
|
||||
}));
|
||||
|
||||
const homeServiceCards = computed(() =>
|
||||
(serviceCards.value.length ? serviceCards.value : homeFallback.service_entries).slice(0, 2).map((card) => {
|
||||
const isZhongjian = card.service_provider === "zhongjian";
|
||||
return {
|
||||
...card,
|
||||
title: isZhongjian ? "中检鉴定" : "安心验 鉴定",
|
||||
tag: isZhongjian ? "权威机构" : "标准服务",
|
||||
description: isZhongjian ? "深圳中检鉴定,适合更高要求的场景" : "由安心验提供标准实物鉴定服务",
|
||||
theme: isZhongjian ? "blue" : "dark",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const categoryCards = computed(() => {
|
||||
const source = categoryDataLoaded.value ? categories.value : homeFallback.category_entries;
|
||||
return source
|
||||
.filter((item) => item.category_name)
|
||||
.map((item) => ({
|
||||
categoryId: item.category_id,
|
||||
categoryName: item.category_name,
|
||||
displayName: item.category_name,
|
||||
visual: resolveCategoryVisual(item),
|
||||
imageUrl: item.image_url || "",
|
||||
}));
|
||||
});
|
||||
|
||||
function goService(provider = "anxinyan") {
|
||||
uni.navigateTo({ url: `/pages/appraisal/service?provider=${provider}` });
|
||||
}
|
||||
|
||||
function goCategory(category: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/appraisal/service?provider=anxinyan&category=${encodeURIComponent(category)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchHome() {
|
||||
pageLoading.value = true;
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await appApi.getHomeData();
|
||||
heroBanner.value = data.banners?.[0] || homeFallback.banners[0];
|
||||
serviceCards.value = data.service_entries?.length ? data.service_entries : homeFallback.service_entries;
|
||||
categories.value = Array.isArray(data.category_entries) ? data.category_entries : [];
|
||||
categoryDataLoaded.value = true;
|
||||
metrics.value = data.trust_metrics?.length ? data.trust_metrics : homeFallback.trust_metrics;
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("home data fallback", error);
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
pageReady.value = true;
|
||||
}
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(fetchHome);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<view v-if="!pageReady && pageLoading" class="home-state notice-card">
|
||||
<view class="notice-card__title">正在加载首页内容</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步服务入口、品类和帮助信息。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="home-state notice-card">
|
||||
<view class="notice-card__title">首页加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="fetchHome">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="home-hero" :style="heroStyle">
|
||||
<view class="home-hero__shade"></view>
|
||||
</view>
|
||||
|
||||
<view class="home-overview">
|
||||
<view class="home-overview__head">
|
||||
<view class="home-brand home-brand--dark">
|
||||
<view class="home-brand__mark"></view>
|
||||
<view class="home-brand__name">安心验</view>
|
||||
</view>
|
||||
<view class="home-overview__divider"></view>
|
||||
<view class="home-overview__partner">中检深圳鉴别合作机构</view>
|
||||
</view>
|
||||
|
||||
<view class="home-metrics">
|
||||
<view v-for="item in homeMetrics" :key="item.label" class="home-metric">
|
||||
<view class="home-metric__value">
|
||||
{{ item.value }}<text class="home-metric__unit">{{ item.unit }}</text>
|
||||
</view>
|
||||
<view class="home-metric__label">{{ item.label }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="home-platform" @click="goService()">
|
||||
<view class="home-platform__content">
|
||||
<view class="home-platform__title">{{ heroBanner.subtitle }}</view>
|
||||
<view class="home-platform__desc">{{ heroBanner.description }}</view>
|
||||
<view class="home-platform__chips">
|
||||
<text class="home-platform__chip">独立第三方</text>
|
||||
<text class="home-platform__chip">报告可验真</text>
|
||||
<text class="home-platform__chip">进度可追踪</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="home-platform__button">立即鉴定</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="home-section">
|
||||
<view class="home-section__title">选择适合您的鉴定服务</view>
|
||||
<view class="home-service-grid">
|
||||
<view
|
||||
v-for="card in homeServiceCards"
|
||||
:key="card.service_provider"
|
||||
:class="['home-service-card', `home-service-card--${card.theme}`]"
|
||||
@click="goService(card.service_provider)"
|
||||
>
|
||||
<view class="home-service-card__watermark">{{ card.theme === "blue" ? "CIC" : "" }}</view>
|
||||
<view class="home-service-card__title">
|
||||
{{ card.title }}
|
||||
<text class="home-service-card__tag">{{ card.tag }}</text>
|
||||
</view>
|
||||
<view class="home-service-card__desc">{{ card.description }}</view>
|
||||
<view class="home-service-card__button">立即鉴定</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="home-section home-section--category">
|
||||
<view class="home-section__title">支持鉴定品类</view>
|
||||
<view class="home-category-grid">
|
||||
<view
|
||||
v-for="item in categoryCards"
|
||||
:key="item.categoryId || item.categoryName"
|
||||
class="home-category-card"
|
||||
@click="goCategory(item.categoryName)"
|
||||
>
|
||||
<view class="home-category-card__title">{{ item.displayName }}</view>
|
||||
<image
|
||||
v-if="item.imageUrl"
|
||||
class="home-category-card__image"
|
||||
:src="item.imageUrl"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view v-else :class="['home-product-thumb', `home-product-thumb--${item.visual}`]"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
background: #f2f2f4;
|
||||
color: #2a2b31;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.home-page,
|
||||
.home-page view,
|
||||
.home-page text {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.home-state {
|
||||
margin: 32rpx;
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 470rpx;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
background-position: center top;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.home-hero__shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(20, 10, 4, 0.1) 54%, rgba(83, 29, 12, 0.3) 100%);
|
||||
}
|
||||
|
||||
.home-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.home-brand__mark {
|
||||
position: relative;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
border: 8rpx solid currentColor;
|
||||
border-radius: 8rpx 8rpx 14rpx 14rpx;
|
||||
}
|
||||
|
||||
.home-brand__mark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -16rpx;
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 0 0 8rpx 8rpx;
|
||||
background: #e6bd16;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.home-brand__name {
|
||||
font-size: 42rpx;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.home-brand__slogan {
|
||||
margin-top: 8rpx;
|
||||
font-size: 18rpx;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.home-brand--dark {
|
||||
flex-shrink: 0;
|
||||
color: #2c2d2f;
|
||||
}
|
||||
|
||||
.home-brand--dark .home-brand__mark {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-width: 6rpx;
|
||||
}
|
||||
|
||||
.home-brand--dark .home-brand__mark::after {
|
||||
bottom: -13rpx;
|
||||
width: 22rpx;
|
||||
height: 22rpx;
|
||||
}
|
||||
|
||||
.home-brand--dark .home-brand__name {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.home-overview {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: calc(100vw - 64rpx);
|
||||
max-width: calc(100vw - 64rpx);
|
||||
margin: -112rpx 32rpx 0;
|
||||
padding: 30rpx 28rpx 32rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.home-overview__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48rpx;
|
||||
}
|
||||
|
||||
.home-overview__divider {
|
||||
width: 2rpx;
|
||||
height: 44rpx;
|
||||
margin: 0 24rpx;
|
||||
background: #b8b8b8;
|
||||
}
|
||||
|
||||
.home-overview__partner {
|
||||
min-width: 0;
|
||||
color: #2d2d32;
|
||||
font-size: 27rpx;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 42rpx;
|
||||
}
|
||||
|
||||
.home-metric {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-metric + .home-metric {
|
||||
border-left: 1rpx solid #dedede;
|
||||
}
|
||||
|
||||
.home-metric__value {
|
||||
color: #e1b606;
|
||||
font-size: 46rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-metric__unit {
|
||||
margin-left: 2rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-metric__label {
|
||||
margin-top: 18rpx;
|
||||
color: #2b2b31;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.home-platform {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 198rpx;
|
||||
margin-top: 48rpx;
|
||||
padding: 36rpx 28rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 12rpx;
|
||||
background:
|
||||
radial-gradient(circle at 80% 30%, rgba(229, 183, 13, 0.5), transparent 8%),
|
||||
radial-gradient(circle at 86% 78%, rgba(229, 183, 13, 0.34), transparent 18%),
|
||||
linear-gradient(110deg, #151515 0%, #1d1e1f 48%, #080808 100%);
|
||||
}
|
||||
|
||||
.home-platform::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -54rpx;
|
||||
right: -92rpx;
|
||||
width: 360rpx;
|
||||
height: 260rpx;
|
||||
border: 3rpx solid rgba(226, 178, 19, 0.48);
|
||||
border-left-color: transparent;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.home-platform::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.18;
|
||||
background-image:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.4) 12%, transparent 12%),
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.28) 12%, transparent 12%);
|
||||
background-size: 34rpx 34rpx;
|
||||
}
|
||||
|
||||
.home-platform__content,
|
||||
.home-platform__button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.home-platform__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-platform__title {
|
||||
color: #e7bc12;
|
||||
font-size: 33rpx;
|
||||
line-height: 1.15;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.home-platform__desc {
|
||||
margin-top: 12rpx;
|
||||
color: #fff;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.home-platform__chips {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.home-platform__chip {
|
||||
height: 34rpx;
|
||||
padding: 0 16rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.75);
|
||||
border-radius: 18rpx;
|
||||
color: #fff;
|
||||
font-size: 18rpx;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.home-platform__button {
|
||||
flex-shrink: 0;
|
||||
min-width: 132rpx;
|
||||
height: 58rpx;
|
||||
margin-left: 14rpx;
|
||||
border-radius: 30rpx;
|
||||
background: #e8b700;
|
||||
color: #fff;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
line-height: 58rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-section {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
margin-top: 66rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.home-section--category {
|
||||
margin-top: 76rpx;
|
||||
}
|
||||
|
||||
.home-section__title {
|
||||
color: #2e3037;
|
||||
font-size: 40rpx;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.home-service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 22rpx;
|
||||
margin-top: 44rpx;
|
||||
}
|
||||
|
||||
.home-service-card {
|
||||
position: relative;
|
||||
min-height: 210rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 10rpx;
|
||||
padding: 28rpx 20rpx 22rpx;
|
||||
}
|
||||
|
||||
.home-service-card--dark {
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.home-service-card--dark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -34rpx;
|
||||
bottom: -44rpx;
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
border-radius: 40rpx 40rpx 54rpx 54rpx;
|
||||
background: rgba(221, 183, 36, 0.42);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.home-service-card--blue {
|
||||
background: #bceeff;
|
||||
color: #08405d;
|
||||
}
|
||||
|
||||
.home-service-card__watermark {
|
||||
position: absolute;
|
||||
right: -16rpx;
|
||||
bottom: 22rpx;
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
border: 8rpx solid rgba(38, 118, 171, 0.22);
|
||||
border-radius: 50%;
|
||||
color: rgba(38, 118, 171, 0.28);
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
line-height: 132rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-service-card__title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
min-width: 0;
|
||||
font-size: 31rpx;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-service-card__tag {
|
||||
height: 28rpx;
|
||||
padding: 0 12rpx;
|
||||
border: 1rpx solid currentColor;
|
||||
border-radius: 16rpx;
|
||||
font-size: 17rpx;
|
||||
line-height: 26rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.home-service-card__desc {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 12rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-service-card__button {
|
||||
position: absolute;
|
||||
left: 20rpx;
|
||||
bottom: 22rpx;
|
||||
z-index: 1;
|
||||
width: 168rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 24rpx;
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-service-card--dark .home-service-card__button {
|
||||
background: #edbd00;
|
||||
}
|
||||
|
||||
.home-service-card--blue .home-service-card__button {
|
||||
background: #006eb8;
|
||||
}
|
||||
|
||||
.home-category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 22rpx;
|
||||
margin-top: 44rpx;
|
||||
}
|
||||
|
||||
.home-category-card {
|
||||
position: relative;
|
||||
height: 184rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 10rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.home-category-card__title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 22rpx 20rpx 0;
|
||||
color: #2d2f36;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.15;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-product-thumb {
|
||||
position: absolute;
|
||||
right: 18rpx;
|
||||
bottom: 8rpx;
|
||||
width: 178rpx;
|
||||
height: 126rpx;
|
||||
background-image: url("../../static/home/home-reference.jpg");
|
||||
background-size: 750rpx 2434rpx;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.home-category-card__image {
|
||||
position: absolute;
|
||||
right: 18rpx;
|
||||
bottom: 8rpx;
|
||||
width: 178rpx;
|
||||
height: 126rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--bag {
|
||||
background-position: -162rpx -1402rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--shoes {
|
||||
width: 166rpx;
|
||||
right: 28rpx;
|
||||
background-position: -520rpx -1412rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--jewelry {
|
||||
height: 138rpx;
|
||||
background-position: -174rpx -1575rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--beauty {
|
||||
width: 166rpx;
|
||||
height: 142rpx;
|
||||
right: 28rpx;
|
||||
background-position: -520rpx -1608rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--watch {
|
||||
width: 156rpx;
|
||||
height: 150rpx;
|
||||
right: 48rpx;
|
||||
background-position: -212rpx -1838rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--clothes {
|
||||
width: 164rpx;
|
||||
height: 148rpx;
|
||||
right: 44rpx;
|
||||
background-position: -500rpx -1836rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--digital {
|
||||
width: 194rpx;
|
||||
height: 140rpx;
|
||||
background-position: -162rpx -2026rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--antique {
|
||||
width: 146rpx;
|
||||
height: 150rpx;
|
||||
right: 54rpx;
|
||||
background-position: -548rpx -2018rpx;
|
||||
}
|
||||
|
||||
.home-product-thumb--default {
|
||||
right: 28rpx;
|
||||
bottom: 18rpx;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 36rpx;
|
||||
background:
|
||||
radial-gradient(circle at 60% 36%, rgba(230, 188, 22, 0.8), transparent 18%),
|
||||
linear-gradient(135deg, rgba(230, 188, 22, 0.2), rgba(45, 47, 54, 0.1));
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.home-overview__partner,
|
||||
.home-platform__desc,
|
||||
.home-service-card__desc {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.home-platform {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.home-platform__button {
|
||||
margin-top: 22rpx;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
178
user-app/src/pages/material-tag/detail.vue
Normal file
178
user-app/src/pages/material-tag/detail.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appApi, type MaterialTagData } from "../../api/app";
|
||||
import { resolveErrorMessage } from "../../utils/feedback";
|
||||
|
||||
const token = ref("");
|
||||
const detail = ref<MaterialTagData | null>(null);
|
||||
const loading = ref(false);
|
||||
const verifying = ref(false);
|
||||
const loadError = ref("");
|
||||
const verifyCode = ref("");
|
||||
const verifyPassed = ref(false);
|
||||
const verifyMessage = ref("");
|
||||
|
||||
const isPublished = computed(() => detail.value?.tag_status === "published");
|
||||
const reportNo = computed(() => detail.value?.report_summary?.report_no || "");
|
||||
const statusTagClass = computed(() => {
|
||||
if (verifyPassed.value) return "tag--success";
|
||||
if (detail.value?.tag_status === "published") return "tag--accent";
|
||||
if (detail.value?.tag_status === "pending_report") return "tag--warning";
|
||||
return "tag--info";
|
||||
});
|
||||
const statusTitle = computed(() => {
|
||||
if (verifyPassed.value) return "组合验真通过";
|
||||
if (!detail.value) return "吊牌验真";
|
||||
return detail.value.status_text;
|
||||
});
|
||||
|
||||
function goReport() {
|
||||
if (!reportNo.value) return;
|
||||
uni.navigateTo({ url: `/pages/report/detail?report_no=${encodeURIComponent(reportNo.value)}` });
|
||||
}
|
||||
|
||||
async function fetchDetail(currentToken: string) {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
detail.value = await appApi.getMaterialTag(currentToken);
|
||||
} catch (error) {
|
||||
console.warn("material tag load failed", error);
|
||||
loadError.value = resolveErrorMessage(error, "吊牌信息加载失败,请稍后重试。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVerifyCode() {
|
||||
if (!detail.value || !isPublished.value) return;
|
||||
const code = verifyCode.value.trim();
|
||||
if (!code) {
|
||||
uni.showToast({ title: "请输入验真编码", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
verifying.value = true;
|
||||
try {
|
||||
const result = await appApi.verifyMaterialTag({
|
||||
token: token.value,
|
||||
report_no: reportNo.value,
|
||||
verify_code: code,
|
||||
});
|
||||
verifyPassed.value = result.verify_passed;
|
||||
verifyMessage.value = result.verify_message;
|
||||
detail.value.verify_count = result.verify_count;
|
||||
uni.showToast({
|
||||
title: result.verify_passed ? "验真通过" : "验真未通过",
|
||||
icon: "none",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("material tag verify failed", error);
|
||||
verifyPassed.value = false;
|
||||
verifyMessage.value = resolveErrorMessage(error, "验真失败,请稍后重试。");
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const currentToken = String(options?.token || "").trim();
|
||||
token.value = currentToken;
|
||||
if (!currentToken) {
|
||||
loadError.value = "缺少吊牌标识,无法加载验真信息。";
|
||||
return;
|
||||
}
|
||||
fetchDetail(currentToken);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在读取吊牌</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步吊牌二维码与关联报告状态。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError" class="section notice-card">
|
||||
<view class="notice-card__title">吊牌信息加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
</view>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="status-card section-card section-card--soft">
|
||||
<text class="tag" :class="statusTagClass">{{ verifyPassed ? "验真通过" : detail.status_text }}</text>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
{{ statusTitle }}
|
||||
</view>
|
||||
<view class="section__desc">{{ verifyMessage || detail.message }}</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">吊牌信息</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">二维码状态</text>
|
||||
<text class="report-meta__value">{{ detail.status_text }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">用户扫码次数</text>
|
||||
<text class="report-meta__value">{{ detail.scan_count }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">验真次数</text>
|
||||
<text class="report-meta__value">{{ detail.verify_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.report_summary" class="section section-card">
|
||||
<view class="section__title">报告摘要</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">报告编号</text>
|
||||
<text class="report-meta__value">{{ detail.report_summary.report_no }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">报告标题</text>
|
||||
<text class="report-meta__value">{{ detail.report_summary.report_title }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">鉴定机构</text>
|
||||
<text class="report-meta__value">{{ detail.report_summary.institution_name }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">发布时间</text>
|
||||
<text class="report-meta__value">{{ detail.report_summary.publish_time || "-" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isPublished" class="section section-card">
|
||||
<view class="section__title">组合验真</view>
|
||||
<view class="section__desc">输入吊牌上的 6 位验真编码,系统将按二维码、报告编号与验真编码组合校验。</view>
|
||||
<view class="field-box" style="margin-top: 20rpx;">
|
||||
<input v-model="verifyCode" class="field-input" maxlength="6" placeholder="请输入验真编码" confirm-type="done" @confirm="submitVerifyCode" />
|
||||
</view>
|
||||
<view style="display:flex; gap:16rpx; margin-top: 20rpx;">
|
||||
<view class="btn btn--primary" :class="verifying ? 'btn--disabled' : ''" @click="submitVerifyCode">
|
||||
{{ verifying ? "验真中..." : "提交验真" }}
|
||||
</view>
|
||||
<view class="btn btn--secondary" @click="goReport">查看报告</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isPublished" class="section section-card">
|
||||
<view class="section__title">商品摘要</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">商品名称</text>
|
||||
<text class="report-meta__value">{{ detail.product_summary.product_name || "-" }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">品类 / 品牌</text>
|
||||
<text class="report-meta__value">{{ detail.product_summary.category_name || "-" }} / {{ detail.product_summary.brand_name || "-" }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">鉴定结论</text>
|
||||
<text class="report-meta__value">{{ detail.result_summary.result_text || "-" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
363
user-app/src/pages/message/index.vue
Normal file
363
user-app/src/pages/message/index.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type MessagePageCopy, type UserMessageItem, type UserMessageListData } from "../../api/app";
|
||||
import { resolveErrorMessage, showErrorToast, showInfoToast } from "../../utils/feedback";
|
||||
|
||||
type MessageCategory = UserMessageListData["summary"]["current_category"];
|
||||
|
||||
function createEmptySummary(): UserMessageListData["summary"] {
|
||||
return {
|
||||
total_count: 0,
|
||||
unread_count: 0,
|
||||
category_counts: {
|
||||
all: 0,
|
||||
order: 0,
|
||||
report: 0,
|
||||
supplement: 0,
|
||||
ticket: 0,
|
||||
},
|
||||
current_count: 0,
|
||||
current_category: "all",
|
||||
unread_only: false,
|
||||
};
|
||||
}
|
||||
|
||||
const messages = ref<UserMessageItem[]>([]);
|
||||
const summary = ref<UserMessageListData["summary"]>(createEmptySummary());
|
||||
const loading = ref(false);
|
||||
const markingAll = ref(false);
|
||||
const currentCategory = ref<MessageCategory>("all");
|
||||
const unreadOnly = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const pageCopy = ref<MessagePageCopy>({
|
||||
title: "服务提醒与处理进度",
|
||||
desc: "这里会统一展示订单流转、补资料、报告出具和工单回复等关键通知,方便您集中查看。",
|
||||
});
|
||||
|
||||
const categoryTabs: Array<{ value: MessageCategory; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "order", label: "订单" },
|
||||
{ value: "report", label: "报告" },
|
||||
{ value: "supplement", label: "补资料" },
|
||||
{ value: "ticket", label: "工单" },
|
||||
];
|
||||
|
||||
const groupedMessages = computed(() => {
|
||||
const groups: Array<{ label: string; items: UserMessageItem[] }> = [];
|
||||
for (const item of messages.value) {
|
||||
const label = resolveDateLabel(item.created_at);
|
||||
const current = groups[groups.length - 1];
|
||||
if (current && current.label === label) {
|
||||
current.items.push(item);
|
||||
} else {
|
||||
groups.push({
|
||||
label,
|
||||
items: [item],
|
||||
});
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const currentCategoryLabel = computed(() => categoryTabs.find((item) => item.value === currentCategory.value)?.label || "全部");
|
||||
|
||||
const emptyState = computed(() => {
|
||||
if (unreadOnly.value) {
|
||||
return {
|
||||
title: "暂无未读消息",
|
||||
desc: "当前筛选条件下没有新的未读通知,您可以切换到全部消息继续查看。",
|
||||
};
|
||||
}
|
||||
|
||||
const currentTab = categoryTabs.find((item) => item.value === currentCategory.value);
|
||||
if (currentCategory.value !== "all") {
|
||||
return {
|
||||
title: `暂无${currentTab?.label || "分类"}消息`,
|
||||
desc: "您可以切换到其他分类,或稍后再回来查看新的服务提醒。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "暂无消息",
|
||||
desc: "新的服务进度、补图提醒和报告通知会展示在这里。",
|
||||
};
|
||||
});
|
||||
|
||||
function resolveDateLabel(value: string) {
|
||||
if (!value) return "更早";
|
||||
const normalized = value.replace(" ", "T");
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const diff = Math.round((today - target) / (24 * 3600 * 1000));
|
||||
|
||||
if (diff === 0) return "今天";
|
||||
if (diff === 1) return "昨天";
|
||||
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function countForCategory(category: MessageCategory) {
|
||||
return summary.value.category_counts[category] ?? 0;
|
||||
}
|
||||
|
||||
function resolveTagClass(item: UserMessageItem) {
|
||||
if (item.category === "report") return "tag--success";
|
||||
if (item.category === "supplement") return "tag--warning";
|
||||
if (item.category === "ticket") return "tag--accent";
|
||||
return "tag--info";
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
loading.value = true;
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
const [messagesResult, metaResult] = await Promise.allSettled([
|
||||
appApi.getMessages({
|
||||
category: currentCategory.value === "all" ? undefined : currentCategory.value,
|
||||
unread_only: unreadOnly.value ? 1 : undefined,
|
||||
}),
|
||||
appApi.getMessageMeta(),
|
||||
]);
|
||||
|
||||
if (messagesResult.status !== "fulfilled") {
|
||||
throw messagesResult.reason;
|
||||
}
|
||||
|
||||
if (metaResult.status === "fulfilled") {
|
||||
pageCopy.value = metaResult.value.message_page_copy;
|
||||
} else {
|
||||
console.warn("message meta load failed", metaResult.reason);
|
||||
}
|
||||
|
||||
messages.value = messagesResult.value.list;
|
||||
summary.value = messagesResult.value.summary;
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("messages fallback", error);
|
||||
if (!pageReady.value) {
|
||||
loadError.value = resolveErrorMessage(error, "消息加载失败,请稍后重试。");
|
||||
} else {
|
||||
showErrorToast(error, "消息加载失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openMessage(item: UserMessageItem) {
|
||||
const wasUnread = !item.is_read;
|
||||
|
||||
if (wasUnread) {
|
||||
try {
|
||||
await appApi.readMessage(item.id);
|
||||
item.is_read = true;
|
||||
summary.value.unread_count = Math.max(0, summary.value.unread_count - 1);
|
||||
if (unreadOnly.value) {
|
||||
messages.value = messages.value.filter((entry) => entry.id !== item.id);
|
||||
summary.value.current_count = Math.max(0, summary.value.current_count - 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("read message failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.target_url) {
|
||||
uni.navigateTo({ url: item.target_url });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: "当前消息暂无可查看详情",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (summary.value.unread_count <= 0) {
|
||||
showInfoToast("当前没有未读消息");
|
||||
return;
|
||||
}
|
||||
|
||||
markingAll.value = true;
|
||||
try {
|
||||
await appApi.readAllMessages();
|
||||
messages.value = messages.value.map((item) => ({
|
||||
...item,
|
||||
is_read: true,
|
||||
}));
|
||||
summary.value.unread_count = 0;
|
||||
if (unreadOnly.value) {
|
||||
messages.value = [];
|
||||
summary.value.current_count = 0;
|
||||
}
|
||||
showInfoToast("已全部标记为已读");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "操作失败");
|
||||
} finally {
|
||||
markingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectCategory(category: MessageCategory) {
|
||||
if (currentCategory.value === category) return;
|
||||
currentCategory.value = category;
|
||||
await fetchMessages();
|
||||
}
|
||||
|
||||
async function toggleUnreadOnly() {
|
||||
unreadOnly.value = !unreadOnly.value;
|
||||
await fetchMessages();
|
||||
}
|
||||
|
||||
onShow(fetchMessages);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载消息</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在整理当前筛选条件下的消息内容。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">消息加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="fetchMessages">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="message-dashboard">
|
||||
<view class="message-dashboard__title">{{ pageCopy.title }}</view>
|
||||
<view class="message-dashboard__desc">{{ pageCopy.desc }}</view>
|
||||
<view class="message-dashboard__head">
|
||||
<text class="message-dashboard__action" @click="markAllRead">
|
||||
{{ markingAll ? "处理中..." : "全部已读" }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="message-dashboard__filter-meta">当前筛选:{{ currentCategoryLabel }}{{ unreadOnly ? " · 仅看未读" : " · 全部状态" }}</view>
|
||||
|
||||
<view class="chip-list" style="margin-top: 18rpx">
|
||||
<view
|
||||
v-for="item in categoryTabs"
|
||||
:key="item.value"
|
||||
:class="['choice-chip', currentCategory === item.value ? 'choice-chip--selected' : '']"
|
||||
@click="selectCategory(item.value)"
|
||||
>
|
||||
{{ item.label }} {{ countForCategory(item.value) }}
|
||||
</view>
|
||||
<view
|
||||
:class="['choice-chip', unreadOnly ? 'choice-chip--selected' : '']"
|
||||
@click="toggleUnreadOnly"
|
||||
>
|
||||
仅看未读
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载消息</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在整理当前筛选条件下的消息内容。</view>
|
||||
</view>
|
||||
|
||||
<view v-for="group in groupedMessages" :key="group.label" class="section">
|
||||
<view class="message-group__label">{{ group.label }}</view>
|
||||
<view
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="section message-card"
|
||||
:class="item.is_read ? '' : 'message-card--unread'"
|
||||
@click="openMessage(item)"
|
||||
>
|
||||
<view class="message-card__top">
|
||||
<view style="flex: 1">
|
||||
<view style="display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap">
|
||||
<text v-if="!item.is_read" class="message-dot"></text>
|
||||
<text class="message-card__title">{{ item.title }}</text>
|
||||
<text class="tag" :class="resolveTagClass(item)">{{ item.category_text }}</text>
|
||||
<text class="tag tag--neutral">{{ item.biz_type_text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="message-card__time">{{ item.created_at }}</text>
|
||||
</view>
|
||||
|
||||
<view class="message-card__content">{{ item.content }}</view>
|
||||
|
||||
<view class="message-card__footer">
|
||||
<text class="message-card__state">{{ item.is_read ? "已读" : "未读" }}</text>
|
||||
<text class="btn btn--ghost">{{ item.target_label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && messages.length === 0" class="section section-card section-note">
|
||||
<view class="section__title">{{ emptyState.title }}</view>
|
||||
<view class="section__desc">{{ emptyState.desc }}</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-dashboard {
|
||||
padding: 30rpx 28rpx 28rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.message-dashboard__title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.message-dashboard__desc {
|
||||
margin-top: 10rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.message-dashboard__head {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.message-dashboard__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-height: 68rpx;
|
||||
padding: 0 26rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #edbd00;
|
||||
color: #ffffff;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.message-dashboard__filter-meta {
|
||||
margin-top: 18rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
839
user-app/src/pages/mine/index.vue
Normal file
839
user-app/src/pages/mine/index.vue
Normal file
@@ -0,0 +1,839 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type SettingsData } from "../../api/app";
|
||||
import { settingsFallback } from "../../mocks/app";
|
||||
|
||||
const profile = ref<SettingsData["profile_info"]>(settingsFallback.profile_info);
|
||||
const itemCount = ref(0);
|
||||
const reportCount = ref(0);
|
||||
const authenticRate = ref(0);
|
||||
const unreadCount = ref(0);
|
||||
const totalValuation = ref(0);
|
||||
|
||||
const displayName = computed(() => profile.value.nickname || "未知的旅行家");
|
||||
const displayAvatar = computed(() => profile.value.avatar || "");
|
||||
const displayInitial = computed(() => displayName.value.slice(0, 1) || "安");
|
||||
|
||||
const formattedValuation = computed(() =>
|
||||
totalValuation.value.toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
function handleEntry(code: string) {
|
||||
if (code === "orders") {
|
||||
uni.switchTab({ url: "/pages/order/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === "reports") {
|
||||
uni.switchTab({ url: "/pages/report/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === "messages") {
|
||||
uni.navigateTo({ url: "/pages/message/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === "support") {
|
||||
uni.navigateTo({ url: "/pages/support/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === "address") {
|
||||
uni.navigateTo({ url: "/pages/address/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === "help") {
|
||||
uni.navigateTo({ url: "/pages/help/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === "settings") {
|
||||
uni.navigateTo({ url: "/pages/settings/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: "该功能正在完善中",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.switchTab({ url: "/pages/home/index" });
|
||||
}
|
||||
|
||||
function handleScan() {
|
||||
uni.scanCode({
|
||||
success: (result) => {
|
||||
const reportNo = String(result.result || "").trim();
|
||||
if (!reportNo) return;
|
||||
uni.navigateTo({ url: `/pages/verify/result?report_no=${encodeURIComponent(reportNo)}` });
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({
|
||||
title: "当前环境暂不支持扫码",
|
||||
icon: "none",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchMineData() {
|
||||
try {
|
||||
const data = await appApi.getMineOverview();
|
||||
profile.value = data.profile_info;
|
||||
itemCount.value = data.asset_summary.item_count;
|
||||
reportCount.value = data.asset_summary.report_count;
|
||||
authenticRate.value = data.asset_summary.authentic_rate;
|
||||
unreadCount.value = data.asset_summary.unread_count;
|
||||
totalValuation.value = data.asset_summary.total_valuation;
|
||||
} catch (error) {
|
||||
console.warn("mine data fallback", error);
|
||||
profile.value = settingsFallback.profile_info;
|
||||
itemCount.value = 0;
|
||||
reportCount.value = 0;
|
||||
authenticRate.value = 0;
|
||||
unreadCount.value = 0;
|
||||
totalValuation.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(fetchMineData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="mine-page">
|
||||
<view class="mine-nav">
|
||||
<view class="mine-nav__home" @click="goHome">
|
||||
<view class="mine-nav__home-roof"></view>
|
||||
<view class="mine-nav__home-body"></view>
|
||||
</view>
|
||||
<view class="mine-nav__title">我的</view>
|
||||
<view class="mine-nav__capsule">
|
||||
<text class="mine-nav__dots">•••</text>
|
||||
<view class="mine-nav__divider"></view>
|
||||
<view class="mine-nav__circle"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mine-profile">
|
||||
<view class="mine-profile__avatar">
|
||||
<image v-if="displayAvatar" class="mine-profile__avatar-image" :src="displayAvatar" mode="aspectFill" />
|
||||
<text v-else class="mine-profile__avatar-initial">{{ displayInitial }}</text>
|
||||
</view>
|
||||
<view class="mine-profile__info">
|
||||
<view class="mine-profile__name">{{ displayName }}</view>
|
||||
<view class="mine-profile__greeting">Hi,欢迎使用安心验~</view>
|
||||
</view>
|
||||
<view class="mine-profile__scan" @click="handleScan">
|
||||
<view class="mine-scan-icon">
|
||||
<view class="mine-scan-icon__cell mine-scan-icon__cell--a"></view>
|
||||
<view class="mine-scan-icon__cell mine-scan-icon__cell--b"></view>
|
||||
<view class="mine-scan-icon__cell mine-scan-icon__cell--c"></view>
|
||||
<view class="mine-scan-icon__line mine-scan-icon__line--a"></view>
|
||||
<view class="mine-scan-icon__line mine-scan-icon__line--b"></view>
|
||||
<view class="mine-scan-icon__line mine-scan-icon__line--c"></view>
|
||||
</view>
|
||||
<view class="mine-profile__scan-text">扫一扫</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mine-asset-card">
|
||||
<view class="mine-asset-card__label">总鉴定估值(元)</view>
|
||||
<view class="mine-asset-card__value">{{ formattedValuation }}</view>
|
||||
<view class="mine-asset-card__stats">
|
||||
<view class="mine-asset-stat">
|
||||
<view class="mine-asset-stat__value">{{ itemCount }}</view>
|
||||
<view class="mine-asset-stat__label">鉴定物品数量(件)</view>
|
||||
</view>
|
||||
<view class="mine-asset-stat">
|
||||
<view class="mine-asset-stat__value">{{ reportCount }}</view>
|
||||
<view class="mine-asset-stat__label">鉴定报告数量(份)</view>
|
||||
</view>
|
||||
<view class="mine-asset-stat">
|
||||
<view class="mine-asset-stat__value">{{ authenticRate }}%</view>
|
||||
<view class="mine-asset-stat__label">鉴定正品率(百分百)</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mine-quick-panel">
|
||||
<view class="mine-quick-item" @click="handleEntry('orders')">
|
||||
<view class="mine-feature-icon mine-feature-icon--orders">
|
||||
<view class="mine-feature-icon__paper"></view>
|
||||
<view class="mine-feature-icon__clip"></view>
|
||||
<view class="mine-feature-icon__line mine-feature-icon__line--one"></view>
|
||||
<view class="mine-feature-icon__line mine-feature-icon__line--two"></view>
|
||||
<view class="mine-feature-icon__line mine-feature-icon__line--three"></view>
|
||||
</view>
|
||||
<view class="mine-quick-item__label">我的订单</view>
|
||||
</view>
|
||||
<view class="mine-quick-item" @click="handleEntry('reports')">
|
||||
<view class="mine-feature-icon mine-feature-icon--reports">
|
||||
<view class="mine-feature-icon__pin"></view>
|
||||
<view class="mine-feature-icon__map-line"></view>
|
||||
</view>
|
||||
<view class="mine-quick-item__label">鉴定报告</view>
|
||||
</view>
|
||||
<view class="mine-quick-item" @click="handleEntry('address')">
|
||||
<view class="mine-feature-icon mine-feature-icon--address">
|
||||
<view class="mine-feature-icon__doc"></view>
|
||||
<view class="mine-feature-icon__fold"></view>
|
||||
<view class="mine-feature-icon__seal"></view>
|
||||
</view>
|
||||
<view class="mine-quick-item__label">地址管理</view>
|
||||
</view>
|
||||
<view class="mine-quick-item" @click="handleEntry('messages')">
|
||||
<view class="mine-feature-icon mine-feature-icon--messages">
|
||||
<view class="mine-feature-icon__bubble"></view>
|
||||
<text v-if="unreadCount > 0" class="mine-feature-icon__badge">{{ unreadCount > 99 ? "99+" : unreadCount }}</text>
|
||||
</view>
|
||||
<view class="mine-quick-item__label">消息中心</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mine-menu-panel">
|
||||
<view class="mine-menu-row" @click="handleEntry('support')">
|
||||
<view class="mine-menu-icon mine-menu-icon--support">
|
||||
<view class="mine-menu-icon__headset"></view>
|
||||
</view>
|
||||
<view class="mine-menu-row__label">联系客服</view>
|
||||
</view>
|
||||
<view class="mine-menu-row" @click="handleEntry('help')">
|
||||
<view class="mine-menu-icon mine-menu-icon--help">?</view>
|
||||
<view class="mine-menu-row__label">帮助中心</view>
|
||||
</view>
|
||||
<view class="mine-menu-row" @click="handleEntry('settings')">
|
||||
<view class="mine-menu-icon mine-menu-icon--settings">
|
||||
<view class="mine-menu-icon__gear"></view>
|
||||
</view>
|
||||
<view class="mine-menu-row__label">设置</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mine-page {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
padding: 88rpx 32rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
background: #f2f2f4;
|
||||
color: #2d2d2f;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.mine-page,
|
||||
.mine-page view,
|
||||
.mine-page text {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mine-nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 76rpx;
|
||||
}
|
||||
|
||||
.mine-nav__title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
color: #272729;
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mine-nav__home {
|
||||
position: relative;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.mine-nav__home-roof {
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: 7rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-top: 4rpx solid #2a2a2c;
|
||||
border-left: 4rpx solid #2a2a2c;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.mine-nav__home-body {
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
bottom: 8rpx;
|
||||
width: 34rpx;
|
||||
height: 30rpx;
|
||||
border: 4rpx solid #2a2a2c;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 8rpx 8rpx;
|
||||
background: linear-gradient(135deg, transparent 0 36%, #f0c000 36% 100%);
|
||||
}
|
||||
|
||||
.mine-nav__capsule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 174rpx;
|
||||
height: 64rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 34rpx;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: blur(12rpx);
|
||||
}
|
||||
|
||||
.mine-nav__dots {
|
||||
color: #050505;
|
||||
font-size: 36rpx;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mine-nav__divider {
|
||||
width: 1rpx;
|
||||
height: 34rpx;
|
||||
background: rgba(20, 20, 20, 0.68);
|
||||
}
|
||||
|
||||
.mine-nav__circle {
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
border: 7rpx solid #070707;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mine-profile {
|
||||
display: grid;
|
||||
grid-template-columns: 96rpx minmax(0, 1fr) 112rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 66rpx;
|
||||
}
|
||||
|
||||
.mine-profile__avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(39, 39, 41, 0.15), rgba(39, 39, 41, 0.02)),
|
||||
#d9d9da;
|
||||
color: #2f2f31;
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mine-profile__avatar-image {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.mine-profile__info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mine-profile__name {
|
||||
color: #252527;
|
||||
font-size: 34rpx;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mine-profile__greeting {
|
||||
margin-top: 20rpx;
|
||||
color: #2d2d2f;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mine-profile__scan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #2d2d2f;
|
||||
}
|
||||
|
||||
.mine-profile__scan-text {
|
||||
margin-top: 12rpx;
|
||||
font-size: 25rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mine-scan-icon {
|
||||
position: relative;
|
||||
width: 58rpx;
|
||||
height: 58rpx;
|
||||
}
|
||||
|
||||
.mine-scan-icon__cell {
|
||||
position: absolute;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border: 5rpx solid #454547;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.mine-scan-icon__cell--a {
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.mine-scan-icon__cell--b {
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.mine-scan-icon__cell--c {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.mine-scan-icon__line {
|
||||
position: absolute;
|
||||
height: 5rpx;
|
||||
border-radius: 4rpx;
|
||||
background: #454547;
|
||||
}
|
||||
|
||||
.mine-scan-icon__line--a {
|
||||
right: 0;
|
||||
top: 34rpx;
|
||||
width: 22rpx;
|
||||
}
|
||||
|
||||
.mine-scan-icon__line--b {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 32rpx;
|
||||
}
|
||||
|
||||
.mine-scan-icon__line--c {
|
||||
right: 10rpx;
|
||||
bottom: 14rpx;
|
||||
width: 22rpx;
|
||||
}
|
||||
|
||||
.mine-asset-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 332rpx;
|
||||
margin-top: 46rpx;
|
||||
padding: 40rpx 24rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background:
|
||||
linear-gradient(115deg, rgba(255, 236, 158, 0.68) 0%, rgba(202, 154, 52, 0.18) 45%, rgba(255, 229, 126, 0.54) 100%),
|
||||
linear-gradient(160deg, #d0aa4a 0%, #f1d77c 52%, #be8e28 100%);
|
||||
box-shadow: 0 16rpx 30rpx rgba(155, 111, 20, 0.12);
|
||||
}
|
||||
|
||||
.mine-asset-card::before,
|
||||
.mine-asset-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mine-asset-card::before {
|
||||
left: -24rpx;
|
||||
right: -24rpx;
|
||||
bottom: 42rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
border-top: 7rpx solid rgba(255, 237, 145, 0.86);
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
|
||||
.mine-asset-card::after {
|
||||
right: -70rpx;
|
||||
top: -68rpx;
|
||||
width: 332rpx;
|
||||
height: 360rpx;
|
||||
border-radius: 48% 0 0 50%;
|
||||
background: linear-gradient(130deg, rgba(255, 245, 180, 0.56), rgba(255, 245, 180, 0.06) 62%);
|
||||
transform: rotate(16deg);
|
||||
}
|
||||
|
||||
.mine-asset-card__label,
|
||||
.mine-asset-card__value,
|
||||
.mine-asset-card__stats {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mine-asset-card__label {
|
||||
color: rgba(45, 45, 47, 0.74);
|
||||
font-size: 25rpx;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mine-asset-card__value {
|
||||
margin-top: 24rpx;
|
||||
color: #303035;
|
||||
font-size: 62rpx;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mine-asset-card__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20rpx;
|
||||
margin-top: 72rpx;
|
||||
}
|
||||
|
||||
.mine-asset-stat {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mine-asset-stat__value {
|
||||
color: #303035;
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mine-asset-stat__label {
|
||||
margin-top: 12rpx;
|
||||
color: rgba(45, 45, 47, 0.74);
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mine-quick-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 22rpx;
|
||||
padding: 42rpx 18rpx 38rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mine-quick-item {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mine-quick-item__label {
|
||||
margin-top: 24rpx;
|
||||
color: #252527;
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mine-feature-icon {
|
||||
position: relative;
|
||||
width: 66rpx;
|
||||
height: 66rpx;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mine-feature-icon--orders .mine-feature-icon__paper {
|
||||
position: absolute;
|
||||
inset: 8rpx 8rpx 0;
|
||||
border-radius: 7rpx;
|
||||
background: linear-gradient(180deg, #f7cd30 0%, #e9b800 100%);
|
||||
box-shadow: inset 0 -6rpx 0 rgba(184, 139, 0, 0.2);
|
||||
}
|
||||
|
||||
.mine-feature-icon--orders .mine-feature-icon__clip {
|
||||
position: absolute;
|
||||
left: 20rpx;
|
||||
top: 0;
|
||||
width: 30rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 0 0 8rpx 8rpx;
|
||||
background: #fff5b8;
|
||||
}
|
||||
|
||||
.mine-feature-icon__line {
|
||||
position: absolute;
|
||||
left: 21rpx;
|
||||
width: 30rpx;
|
||||
height: 5rpx;
|
||||
border-radius: 4rpx;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.mine-feature-icon__line--one {
|
||||
top: 26rpx;
|
||||
}
|
||||
|
||||
.mine-feature-icon__line--two {
|
||||
top: 38rpx;
|
||||
}
|
||||
|
||||
.mine-feature-icon__line--three {
|
||||
top: 50rpx;
|
||||
width: 24rpx;
|
||||
}
|
||||
|
||||
.mine-feature-icon--reports .mine-feature-icon__pin {
|
||||
position: absolute;
|
||||
left: 20rpx;
|
||||
top: 0;
|
||||
width: 28rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
background: linear-gradient(180deg, #ffd54d, #e8b700);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.mine-feature-icon--reports .mine-feature-icon__pin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8rpx;
|
||||
top: 8rpx;
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.mine-feature-icon__map-line {
|
||||
position: absolute;
|
||||
left: 6rpx;
|
||||
right: 6rpx;
|
||||
bottom: 4rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 8rpx;
|
||||
background:
|
||||
linear-gradient(145deg, transparent 0 34%, rgba(255, 255, 255, 0.55) 35% 42%, transparent 43% 100%),
|
||||
linear-gradient(180deg, #f8cc2f, #e7b600);
|
||||
}
|
||||
|
||||
.mine-feature-icon--address .mine-feature-icon__doc {
|
||||
position: absolute;
|
||||
inset: 6rpx 12rpx 0 10rpx;
|
||||
border-radius: 5rpx;
|
||||
background:
|
||||
repeating-linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0 5rpx, transparent 5rpx 13rpx),
|
||||
linear-gradient(180deg, #ffd64d, #e8b700);
|
||||
}
|
||||
|
||||
.mine-feature-icon__fold {
|
||||
position: absolute;
|
||||
right: 12rpx;
|
||||
top: 6rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
background: #fff0a0;
|
||||
clip-path: polygon(0 0, 100% 100%, 0 100%);
|
||||
}
|
||||
|
||||
.mine-feature-icon__seal {
|
||||
position: absolute;
|
||||
right: 7rpx;
|
||||
bottom: 1rpx;
|
||||
width: 22rpx;
|
||||
height: 22rpx;
|
||||
border-radius: 50%;
|
||||
background: #f6c500;
|
||||
box-shadow: 0 0 0 5rpx rgba(255, 246, 190, 0.7);
|
||||
}
|
||||
|
||||
.mine-feature-icon__seal::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 7rpx;
|
||||
top: 4rpx;
|
||||
width: 7rpx;
|
||||
height: 12rpx;
|
||||
border-right: 4rpx solid #fff;
|
||||
border-bottom: 4rpx solid #fff;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.mine-feature-icon--messages .mine-feature-icon__bubble {
|
||||
position: absolute;
|
||||
inset: 6rpx 4rpx 10rpx;
|
||||
border-radius: 10rpx;
|
||||
background: linear-gradient(180deg, #ffd64d, #e8b700);
|
||||
}
|
||||
|
||||
.mine-feature-icon--messages .mine-feature-icon__bubble::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
bottom: -10rpx;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
background: #e8b700;
|
||||
clip-path: polygon(0 0, 100% 0, 0 100%);
|
||||
}
|
||||
|
||||
.mine-feature-icon--messages .mine-feature-icon__bubble::before {
|
||||
content: "•••";
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
top: 10rpx;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.mine-feature-icon__badge {
|
||||
position: absolute;
|
||||
right: -13rpx;
|
||||
top: -11rpx;
|
||||
min-width: 30rpx;
|
||||
height: 30rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f03d2f;
|
||||
color: #fff;
|
||||
font-size: 18rpx;
|
||||
line-height: 30rpx;
|
||||
}
|
||||
|
||||
.mine-menu-panel {
|
||||
margin-top: 24rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mine-menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.mine-menu-row + .mine-menu-row {
|
||||
border-top: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.mine-menu-icon {
|
||||
position: relative;
|
||||
flex: 0 0 52rpx;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #e3b900;
|
||||
}
|
||||
|
||||
.mine-menu-row__label {
|
||||
margin-left: 10rpx;
|
||||
color: #252527;
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mine-menu-icon__headset {
|
||||
position: absolute;
|
||||
left: 5rpx;
|
||||
top: 7rpx;
|
||||
width: 40rpx;
|
||||
height: 38rpx;
|
||||
border: 4rpx solid #e3b900;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50% 50% 42% 42%;
|
||||
}
|
||||
|
||||
.mine-menu-icon__headset::before,
|
||||
.mine-menu-icon__headset::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
width: 10rpx;
|
||||
height: 18rpx;
|
||||
border-radius: 6rpx;
|
||||
background: #e3b900;
|
||||
}
|
||||
|
||||
.mine-menu-icon__headset::before {
|
||||
left: -6rpx;
|
||||
}
|
||||
|
||||
.mine-menu-icon__headset::after {
|
||||
right: -6rpx;
|
||||
}
|
||||
|
||||
.mine-menu-icon--help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 4rpx solid #e3b900;
|
||||
border-radius: 50%;
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mine-menu-icon__gear {
|
||||
position: absolute;
|
||||
left: 7rpx;
|
||||
top: 7rpx;
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
border: 5rpx solid #e3b900;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mine-menu-icon__gear::before,
|
||||
.mine-menu-icon__gear::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 11rpx;
|
||||
top: -10rpx;
|
||||
width: 8rpx;
|
||||
height: 50rpx;
|
||||
border-radius: 6rpx;
|
||||
background: #e3b900;
|
||||
}
|
||||
|
||||
.mine-menu-icon__gear::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.mine-page {
|
||||
padding-left: 24rpx;
|
||||
padding-right: 24rpx;
|
||||
}
|
||||
|
||||
.mine-asset-card__value {
|
||||
font-size: 54rpx;
|
||||
}
|
||||
|
||||
.mine-asset-card__stats {
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.mine-asset-stat__label {
|
||||
font-size: 18rpx;
|
||||
}
|
||||
|
||||
.mine-quick-item__label {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1076
user-app/src/pages/order/detail.vue
Normal file
1076
user-app/src/pages/order/detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
500
user-app/src/pages/order/index.vue
Normal file
500
user-app/src/pages/order/index.vue
Normal file
@@ -0,0 +1,500 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type OrderListItem } from "../../api/app";
|
||||
import { isLoggedIn, redirectToLogin } from "../../utils/auth";
|
||||
import { showErrorToast } from "../../utils/feedback";
|
||||
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
|
||||
|
||||
const orders = ref<OrderListItem[]>([]);
|
||||
const privacyMode = ref(getPrivacyMode());
|
||||
const orderHeroBackground = ref("");
|
||||
const defaultOrderHeroBackground = "/static/order/order-reference.jpg";
|
||||
|
||||
const orderHeroStyle = computed(() => ({
|
||||
backgroundImage: `url("${orderHeroBackground.value || defaultOrderHeroBackground}")`,
|
||||
}));
|
||||
|
||||
const heroStats = computed(() => {
|
||||
const stats = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
};
|
||||
|
||||
for (const item of orders.value) {
|
||||
if (["report_published", "completed"].includes(item.order_status)) {
|
||||
stats.completed += 1;
|
||||
continue;
|
||||
}
|
||||
if (item.order_status === "pending_supplement") {
|
||||
stats.pending += 1;
|
||||
continue;
|
||||
}
|
||||
stats.processing += 1;
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
|
||||
const emptyState = computed(() => ({
|
||||
title: "还没有鉴定订单",
|
||||
desc: "发起第一笔鉴定后,订单进度、补资料提醒和报告状态都会集中展示在这里。",
|
||||
}));
|
||||
|
||||
function openOrder(id: number) {
|
||||
uni.navigateTo({ url: `/pages/order/detail?id=${id}` });
|
||||
}
|
||||
|
||||
function goStartAppraisal() {
|
||||
uni.navigateTo({ url: "/pages/appraisal/service" });
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.switchTab({ url: "/pages/home/index" });
|
||||
}
|
||||
|
||||
function goHelp() {
|
||||
uni.navigateTo({ url: "/pages/help/index" });
|
||||
}
|
||||
|
||||
async function fetchPageVisuals() {
|
||||
try {
|
||||
const data = await appApi.getPageVisuals();
|
||||
orderHeroBackground.value = data.order_background_image_url || "";
|
||||
} catch (error) {
|
||||
console.warn("order page visuals fallback", error);
|
||||
}
|
||||
}
|
||||
|
||||
onShow(async () => {
|
||||
privacyMode.value = getPrivacyMode();
|
||||
void fetchPageVisuals();
|
||||
if (!isLoggedIn()) {
|
||||
orders.value = [];
|
||||
redirectToLogin("/pages/order/index");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await appApi.getOrders();
|
||||
orders.value = data.list;
|
||||
} catch (error) {
|
||||
orders.value = [];
|
||||
showErrorToast(error, "订单加载失败");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="order-page">
|
||||
<view :class="['order-hero', orders.length === 0 ? 'order-hero--empty' : 'order-hero--list']">
|
||||
<view class="order-hero__bg" :style="orderHeroStyle"></view>
|
||||
<view class="order-nav">
|
||||
<view class="order-nav__home" @click="goHome">
|
||||
<view class="order-nav__home-roof"></view>
|
||||
<view class="order-nav__home-body"></view>
|
||||
</view>
|
||||
<view class="order-nav__title">订单中心</view>
|
||||
<view class="order-nav__capsule">
|
||||
<text class="order-nav__dots">•••</text>
|
||||
<view class="order-nav__divider"></view>
|
||||
<view class="order-nav__circle"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-hero__content">
|
||||
<view class="order-hero__title">订单中心</view>
|
||||
<view class="order-hero__desc">查看每一笔鉴定从下单、寄送、补资料到出报告的完整进度。</view>
|
||||
|
||||
<view class="order-stat-grid">
|
||||
<view class="order-stat-card">
|
||||
<view class="order-stat-card__value">{{ heroStats.pending }}</view>
|
||||
<view class="order-stat-card__label">待处理</view>
|
||||
</view>
|
||||
<view class="order-stat-card">
|
||||
<view class="order-stat-card__value">{{ heroStats.processing }}</view>
|
||||
<view class="order-stat-card__label">进行中</view>
|
||||
</view>
|
||||
<view class="order-stat-card">
|
||||
<view class="order-stat-card__value">{{ heroStats.completed }}</view>
|
||||
<view class="order-stat-card__label">已完成</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="orders.length === 0" class="order-empty-state">
|
||||
<view class="order-empty-state__title">{{ emptyState.title }}</view>
|
||||
<view class="order-empty-state__desc">{{ emptyState.desc }}</view>
|
||||
<view class="order-empty-state__actions">
|
||||
<view class="order-empty-state__button order-empty-state__button--primary" @click="goStartAppraisal">发起鉴定</view>
|
||||
<view class="order-empty-state__button order-empty-state__button--secondary" @click="goHelp">查看帮助</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="order-list">
|
||||
<view
|
||||
v-for="item in orders"
|
||||
:key="item.order_id"
|
||||
class="order-card"
|
||||
@click="openOrder(item.order_id)"
|
||||
>
|
||||
<view class="order-card__top">
|
||||
<view>
|
||||
<view class="order-card__title">{{ item.product_name }}</view>
|
||||
<view class="order-card__no">订单号:{{ maskOrderNo(item.order_no, privacyMode) }}</view>
|
||||
</view>
|
||||
<text
|
||||
class="order-card__status"
|
||||
:class="item.order_status === 'pending_supplement' ? 'order-card__status--warning' : ['report_published', 'completed'].includes(item.order_status) ? 'order-card__status--success' : 'order-card__status--info'"
|
||||
>
|
||||
{{ item.display_status }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="order-card__desc">{{ item.status_desc }}</view>
|
||||
<view v-if="item.order_status === 'report_published'" class="order-card__desc">平台待安排寄回,请先确认寄回地址。</view>
|
||||
<view v-if="item.order_status === 'completed' && item.display_status === '物品已寄回'" class="order-card__desc">平台已回寄商品,请留意签收物流。</view>
|
||||
<view class="order-card__footer">
|
||||
<view class="order-card__provider">{{ item.service_provider === "zhongjian" ? "中检鉴定" : "安心验鉴定" }}</view>
|
||||
<view class="order-card__action">{{ item.primary_action }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.order-page {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
background: #f2f2f4;
|
||||
color: #2d2d2f;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.order-page,
|
||||
.order-page view,
|
||||
.order-page text {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.order-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
padding: 88rpx 32rpx 0;
|
||||
}
|
||||
|
||||
.order-hero--empty {
|
||||
height: 900rpx;
|
||||
}
|
||||
|
||||
.order-hero--list {
|
||||
height: 790rpx;
|
||||
}
|
||||
|
||||
.order-hero__bg {
|
||||
position: absolute;
|
||||
inset: -18rpx;
|
||||
background-size: 100vw auto;
|
||||
background-position: top center;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(8rpx) saturate(1.08);
|
||||
transform: scale(1.04);
|
||||
opacity: 0.82;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.order-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 239, 232, 0.08) 0%, rgba(243, 243, 245, 0.22) 42%, #f2f2f4 88%),
|
||||
linear-gradient(0deg, rgba(242, 242, 244, 0.36), rgba(255, 255, 255, 0.04));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.order-nav,
|
||||
.order-hero__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.order-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 76rpx;
|
||||
}
|
||||
|
||||
.order-nav__title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
color: #272729;
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-nav__home {
|
||||
position: relative;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.order-nav__home-roof {
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: 7rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-top: 4rpx solid #2a2a2c;
|
||||
border-left: 4rpx solid #2a2a2c;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.order-nav__home-body {
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
bottom: 8rpx;
|
||||
width: 34rpx;
|
||||
height: 30rpx;
|
||||
border: 4rpx solid #2a2a2c;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 8rpx 8rpx;
|
||||
background: linear-gradient(135deg, transparent 0 36%, #f0c000 36% 100%);
|
||||
}
|
||||
|
||||
.order-nav__capsule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 174rpx;
|
||||
height: 64rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 34rpx;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: blur(12rpx);
|
||||
}
|
||||
|
||||
.order-nav__dots {
|
||||
color: #050505;
|
||||
font-size: 36rpx;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.order-nav__divider {
|
||||
width: 1rpx;
|
||||
height: 34rpx;
|
||||
background: rgba(20, 20, 20, 0.68);
|
||||
}
|
||||
|
||||
.order-nav__circle {
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
border: 7rpx solid #070707;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.order-hero__content {
|
||||
margin-top: 214rpx;
|
||||
}
|
||||
|
||||
.order-hero__title {
|
||||
color: #262628;
|
||||
font-size: 70rpx;
|
||||
line-height: 1.08;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.order-hero__desc {
|
||||
width: 650rpx;
|
||||
max-width: 100%;
|
||||
margin-top: 28rpx;
|
||||
color: #666;
|
||||
font-size: 29rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.order-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 42rpx;
|
||||
margin: 26rpx 22rpx 0;
|
||||
}
|
||||
|
||||
.order-stat-card {
|
||||
height: 176rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
text-align: center;
|
||||
box-shadow: 0 10rpx 24rpx rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.order-stat-card__value {
|
||||
margin-top: 35rpx;
|
||||
color: #2b2b2d;
|
||||
font-size: 68rpx;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-stat-card__label {
|
||||
margin-top: 18rpx;
|
||||
color: #686868;
|
||||
font-size: 25rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.order-empty-state {
|
||||
margin: 190rpx 32rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.order-empty-state__title {
|
||||
color: #202022;
|
||||
font-size: 40rpx;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.order-empty-state__desc {
|
||||
width: 560rpx;
|
||||
max-width: 100%;
|
||||
margin: 28rpx auto 0;
|
||||
color: #818181;
|
||||
font-size: 29rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.order-empty-state__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 38rpx;
|
||||
margin-top: 50rpx;
|
||||
}
|
||||
|
||||
.order-empty-state__button {
|
||||
height: 68rpx;
|
||||
border-radius: 36rpx;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
line-height: 68rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.order-empty-state__button--primary {
|
||||
background: #edbd00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.order-empty-state__button--secondary {
|
||||
background: #fff;
|
||||
color: #29292b;
|
||||
}
|
||||
|
||||
.order-list {
|
||||
display: grid;
|
||||
gap: 22rpx;
|
||||
margin: -22rpx 32rpx 0;
|
||||
padding-bottom: 36rpx;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
padding: 28rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 12rpx 26rpx rgba(0, 0, 0, 0.035);
|
||||
}
|
||||
|
||||
.order-card__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.order-card__title {
|
||||
color: #242426;
|
||||
font-size: 31rpx;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.order-card__no,
|
||||
.order-card__desc {
|
||||
margin-top: 12rpx;
|
||||
color: #777;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.order-card__status {
|
||||
flex-shrink: 0;
|
||||
height: 42rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: 22rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.order-card__status--warning {
|
||||
background: #fff4d9;
|
||||
color: #a97700;
|
||||
}
|
||||
|
||||
.order-card__status--success {
|
||||
background: #eaf7ef;
|
||||
color: #2d7652;
|
||||
}
|
||||
|
||||
.order-card__status--info {
|
||||
background: #edf4ff;
|
||||
color: #28669f;
|
||||
}
|
||||
|
||||
.order-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.order-card__provider {
|
||||
color: #9a9a9a;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.order-card__action {
|
||||
min-width: 126rpx;
|
||||
height: 48rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 25rpx;
|
||||
background: #f1bd00;
|
||||
color: #fff;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.order-stat-grid {
|
||||
gap: 22rpx;
|
||||
margin-left: 12rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.order-empty-state__actions {
|
||||
gap: 22rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
627
user-app/src/pages/order/shipping.vue
Normal file
627
user-app/src/pages/order/shipping.vue
Normal file
@@ -0,0 +1,627 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type ShippingDetailData } from "../../api/app";
|
||||
import { shippingDetailFallback } from "../../mocks/app";
|
||||
import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
|
||||
|
||||
const detail = ref<ShippingDetailData>(shippingDetailFallback);
|
||||
const orderId = ref(0);
|
||||
const saving = ref(false);
|
||||
const expressCompany = ref("");
|
||||
const trackingNo = ref("");
|
||||
const privacyMode = ref(getPrivacyMode());
|
||||
const selectedWarehouseId = ref(0);
|
||||
const warehouseSheetVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
|
||||
const submitted = computed(() => detail.value.logistics_info.is_submitted);
|
||||
const canEditTracking = computed(() => !submitted.value && detail.value.can_submit_tracking);
|
||||
const hasWarehouseChoices = computed(
|
||||
() => detail.value.shipping_options.can_select_warehouse && detail.value.shipping_options.list.length > 1,
|
||||
);
|
||||
const warehouseOptions = computed(() =>
|
||||
[...detail.value.shipping_options.list].sort((left, right) => {
|
||||
const leftSelected = left.id === selectedWarehouseId.value ? 1 : 0;
|
||||
const rightSelected = right.id === selectedWarehouseId.value ? 1 : 0;
|
||||
if (leftSelected !== rightSelected) {
|
||||
return rightSelected - leftSelected;
|
||||
}
|
||||
|
||||
const leftRecommended = left.is_recommended ? 1 : 0;
|
||||
const rightRecommended = right.is_recommended ? 1 : 0;
|
||||
if (leftRecommended !== rightRecommended) {
|
||||
return rightRecommended - leftRecommended;
|
||||
}
|
||||
|
||||
return left.sort_order - right.sort_order;
|
||||
}),
|
||||
);
|
||||
const selectedWarehouse = computed(() => {
|
||||
const matched = warehouseOptions.value.find((item) => item.id === selectedWarehouseId.value);
|
||||
return matched || null;
|
||||
});
|
||||
const alternativeWarehouses = computed(() => warehouseOptions.value.filter((item) => item.id !== selectedWarehouseId.value));
|
||||
const activeShippingAddress = computed(() => selectedWarehouse.value || detail.value.shipping_address);
|
||||
const selectionChanged = computed(() => {
|
||||
const initialWarehouseId = detail.value.shipping_options.current_warehouse_id || detail.value.shipping_address.warehouse_id || 0;
|
||||
return Boolean(initialWarehouseId && selectedWarehouseId.value && initialWarehouseId !== selectedWarehouseId.value);
|
||||
});
|
||||
const activeWarehouseServiceText = computed(
|
||||
() => selectedWarehouse.value?.service_provider_text || detail.value.order_info.service_provider || "当前鉴定服务",
|
||||
);
|
||||
const activeWarehouseHint = computed(() => {
|
||||
if (!hasWarehouseChoices.value) {
|
||||
return "该订单当前仅匹配这一家检测中心,可直接按下方收件信息寄送。";
|
||||
}
|
||||
if (selectionChanged.value) {
|
||||
return "你已切换检测中心,提交运单后将按当前选择的仓库收货。";
|
||||
}
|
||||
return "寄送前可重新选择检测中心,避免商品寄错仓库。";
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
function openWarehouseSheet() {
|
||||
if (!hasWarehouseChoices.value) return;
|
||||
warehouseSheetVisible.value = true;
|
||||
}
|
||||
|
||||
function closeWarehouseSheet() {
|
||||
warehouseSheetVisible.value = false;
|
||||
}
|
||||
|
||||
function selectWarehouse(warehouseId: number) {
|
||||
const target = warehouseOptions.value.find((item) => item.id === warehouseId);
|
||||
if (!target) return;
|
||||
|
||||
const changed = selectedWarehouseId.value !== warehouseId;
|
||||
selectedWarehouseId.value = warehouseId;
|
||||
closeWarehouseSheet();
|
||||
|
||||
if (changed) {
|
||||
showInfoToast(`已切换至${target.warehouse_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyAddress() {
|
||||
const current = activeShippingAddress.value;
|
||||
const address = `${current.receiver_name} ${current.receiver_mobile} ${current.province}${current.city}${current.district}${current.detail_address}`;
|
||||
uni.setClipboardData({
|
||||
data: address,
|
||||
success: () => showInfoToast("收件信息已复制"),
|
||||
});
|
||||
}
|
||||
|
||||
function useCompany(name: string) {
|
||||
expressCompany.value = name;
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!orderId.value) return;
|
||||
loading.value = true;
|
||||
privacyMode.value = getPrivacyMode();
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
detail.value = await appApi.getOrderShippingDetail(orderId.value);
|
||||
expressCompany.value = detail.value.logistics_info.express_company || expressCompany.value;
|
||||
trackingNo.value = detail.value.logistics_info.tracking_no || trackingNo.value;
|
||||
selectedWarehouseId.value = detail.value.shipping_options.current_warehouse_id || detail.value.shipping_address.warehouse_id || 0;
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("shipping detail fallback", error);
|
||||
if (!pageReady.value) {
|
||||
loadError.value = resolveErrorMessage(error, "寄送信息加载失败,请稍后重试。");
|
||||
} else {
|
||||
showErrorToast(error, "寄送信息刷新失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTracking() {
|
||||
if (submitted.value) {
|
||||
showInfoToast("运单已提交,如需更正请联系客服处理");
|
||||
return;
|
||||
}
|
||||
if (!detail.value.can_submit_tracking) {
|
||||
showInfoToast("当前订单暂不需要提交运单");
|
||||
return;
|
||||
}
|
||||
if (!expressCompany.value.trim() || !trackingNo.value.trim()) {
|
||||
showInfoToast("请填写快递公司和运单号");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await withLoading("正在提交运单", async () =>
|
||||
appApi.saveOrderShipping({
|
||||
order_id: detail.value.order_info.order_id,
|
||||
express_company: expressCompany.value.trim(),
|
||||
tracking_no: trackingNo.value.trim(),
|
||||
warehouse_id: selectedWarehouseId.value || undefined,
|
||||
}),
|
||||
);
|
||||
showInfoToast("运单已提交");
|
||||
await fetchDetail();
|
||||
} catch (error) {
|
||||
showErrorToast(error, "运单提交失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
orderId.value = Number(options?.order_id || 0);
|
||||
if (!orderId.value) {
|
||||
loadError.value = "缺少订单编号,无法查看寄送信息。";
|
||||
}
|
||||
});
|
||||
|
||||
onShow(fetchDetail);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载寄送信息</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步收货仓库、物流状态与可选检测中心。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">寄送信息加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="fetchDetail">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="section-card">
|
||||
<view class="tag" :class="submitted ? 'tag--info' : 'tag--warning'">{{ detail.order_info.display_status }}</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
查看寄送与提交运单
|
||||
</view>
|
||||
<view class="section__desc">请先寄出商品,再回到本页填写快递公司和运单号,我们会同步更新鉴定进度。</view>
|
||||
<view class="certificate-header__meta">
|
||||
<text class="certificate-meta-chip">订单号 {{ maskOrderNo(detail.order_info.order_no, privacyMode) }}</text>
|
||||
<text class="certificate-meta-chip">鉴定单号 {{ maskOrderNo(detail.order_info.appraisal_no, privacyMode) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">收件信息</view>
|
||||
<view v-if="detail.shipping_options.list.length" class="warehouse-picker">
|
||||
<view class="warehouse-current">
|
||||
<view class="warehouse-current__header">
|
||||
<view>
|
||||
<view class="warehouse-current__eyebrow">当前检测中心</view>
|
||||
<view class="warehouse-current__name">{{ activeShippingAddress.warehouse_name || "检测中心待确认" }}</view>
|
||||
</view>
|
||||
<text class="warehouse-current__badge">{{ selectionChanged ? "已切换待生效" : "当前选择" }}</text>
|
||||
</view>
|
||||
|
||||
<view class="warehouse-current__meta-row">
|
||||
<text class="warehouse-current__meta-pill">{{ activeWarehouseServiceText }}</text>
|
||||
<text v-if="hasWarehouseChoices" class="warehouse-current__meta-pill">{{ alternativeWarehouses.length }} 个可切换</text>
|
||||
</view>
|
||||
<view class="warehouse-current__notice">{{ activeWarehouseHint }}</view>
|
||||
<view v-if="hasWarehouseChoices" class="warehouse-current__action" @click="openWarehouseSheet">
|
||||
<text>更换检测中心</text>
|
||||
<text class="warehouse-current__action-arrow">去选择</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="shipping-detail__header">
|
||||
<view class="shipping-detail__title">详细收件资料</view>
|
||||
<view class="shipping-detail__desc">请按以下联系人、地址与签收时间完成寄送。</view>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">联系人</text>
|
||||
<text class="report-meta__value">{{ activeShippingAddress.receiver_name }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">联系电话</text>
|
||||
<text class="report-meta__value">{{ activeShippingAddress.receiver_mobile }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row report-meta__row--stacked">
|
||||
<text class="report-meta__label">详细地址</text>
|
||||
<text class="report-meta__value">{{ activeShippingAddress.province }}{{ activeShippingAddress.city }}{{ activeShippingAddress.district }}{{ activeShippingAddress.detail_address }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">签收时间</text>
|
||||
<text class="report-meta__value">{{ activeShippingAddress.service_time }}</text>
|
||||
</view>
|
||||
<view class="section__desc">{{ activeShippingAddress.notice }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="copyAddress">复制收件信息</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section grid-2">
|
||||
<view class="metric-card">
|
||||
<view class="metric-card__value">{{ detail.order_info.product_name || "待确认商品" }}</view>
|
||||
<view class="metric-card__label">当前寄送商品,请确保与订单信息一致</view>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<view class="metric-card__value">{{ submitted ? detail.logistics_info.tracking_status_text : "待提交" }}</view>
|
||||
<view class="metric-card__label">寄送状态,提交运单后我们会继续同步节点</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">寄送提醒</view>
|
||||
<view v-for="(item, index) in detail.shipping_notice.tips" :key="item" class="report-meta__row report-meta__row--stacked">
|
||||
<text class="report-meta__label">{{ index + 1 }}. {{ item }}</text>
|
||||
</view>
|
||||
<view class="chip-list">
|
||||
<view v-for="company in detail.shipping_notice.express_recommendations" :key="company" class="choice-chip" @click="useCompany(company)">
|
||||
{{ company }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view :class="['section', 'form-panel', submitted ? 'form-panel--readonly' : '']">
|
||||
<view class="form-panel__title">{{ submitted ? "运单信息" : "填写运单信息" }}</view>
|
||||
<view v-if="submitted" class="form-panel__readonly-tip">
|
||||
运单已提交,当前页面仅展示已登记信息,避免误触修改。
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">快递公司</view>
|
||||
<view :class="['field-box', submitted ? 'field-box--readonly' : '']">
|
||||
<input v-model="expressCompany" class="field-input" maxlength="30" placeholder="例如:顺丰速运" :disabled="submitted" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">运单号</view>
|
||||
<view :class="['field-box', submitted ? 'field-box--readonly' : '']">
|
||||
<input v-model="trackingNo" class="field-input" maxlength="40" placeholder="请输入快递单号" :disabled="submitted" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-group__hint">
|
||||
{{ submitted ? "如物流信息存在异常,请联系平台客服协助处理。" : "提交后将进入待签收跟踪状态,请确认信息无误后再提交。" }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="submitted" class="section timeline-panel">
|
||||
<view class="section__title">寄送轨迹</view>
|
||||
<view class="section__desc">{{ detail.logistics_info.latest_desc }}</view>
|
||||
<view class="timeline" style="margin-top: 24rpx">
|
||||
<view
|
||||
v-for="item in detail.logistics_nodes"
|
||||
:key="`${item.node_time}-${item.node_desc}`"
|
||||
class="timeline__item timeline__item--active"
|
||||
>
|
||||
<view class="timeline-card__title">{{ item.node_desc }}</view>
|
||||
<view class="timeline__time">{{ item.node_time }}</view>
|
||||
<view v-if="item.node_location" class="timeline-card__desc">{{ item.node_location }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view :class="['btn', 'btn--primary', submitted || saving || !canEditTracking ? 'btn--disabled' : '']" @click="submitTracking">
|
||||
{{ saving ? "提交中..." : submitted ? "运单已提交" : "提交运单" }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="warehouseSheetVisible" class="warehouse-sheet">
|
||||
<view class="warehouse-sheet__mask" @click="closeWarehouseSheet"></view>
|
||||
<view class="warehouse-sheet__panel">
|
||||
<view class="warehouse-sheet__header">
|
||||
<view>
|
||||
<view class="warehouse-sheet__title">选择检测中心</view>
|
||||
<view class="warehouse-sheet__desc">切换后,下方收件资料会同步更新,提交运单时自动保存当前选择。</view>
|
||||
</view>
|
||||
<view class="warehouse-sheet__close" @click="closeWarehouseSheet">关闭</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="warehouse-sheet__list">
|
||||
<view
|
||||
v-for="item in warehouseOptions"
|
||||
:key="item.id"
|
||||
:class="['warehouse-sheet__option', selectedWarehouseId === item.id ? 'warehouse-sheet__option--selected' : '']"
|
||||
@click="selectWarehouse(item.id)"
|
||||
>
|
||||
<view class="warehouse-sheet__top">
|
||||
<view class="warehouse-sheet__head">
|
||||
<view class="warehouse-sheet__name">{{ item.warehouse_name }}</view>
|
||||
<view class="warehouse-sheet__badges">
|
||||
<text v-if="item.is_recommended" class="warehouse-sheet__badge warehouse-sheet__badge--accent">推荐</text>
|
||||
<text v-if="item.is_default" class="warehouse-sheet__badge">默认</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="warehouse-sheet__state">{{ selectedWarehouseId === item.id ? "已选中心" : "选择" }}</text>
|
||||
</view>
|
||||
<view class="warehouse-sheet__meta">{{ item.service_provider_text }} / {{ item.service_time }}</view>
|
||||
<view class="warehouse-sheet__meta">{{ item.full_address }}</view>
|
||||
<view v-if="item.is_recommended && item.recommended_reason" class="warehouse-sheet__reason">
|
||||
{{ item.recommended_reason }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warehouse-picker {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.warehouse-current {
|
||||
padding: 26rpx;
|
||||
border-radius: 32rpx;
|
||||
background:
|
||||
linear-gradient(150deg, rgba(29, 29, 29, 0.98) 0%, rgba(52, 44, 31, 0.98) 100%);
|
||||
box-shadow: 0 14rpx 32rpx rgba(26, 20, 12, 0.16);
|
||||
}
|
||||
|
||||
.warehouse-current__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.warehouse-current__eyebrow {
|
||||
color: rgba(244, 215, 157, 0.82);
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.warehouse-current__name {
|
||||
margin-top: 10rpx;
|
||||
color: #fffaf0;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warehouse-current__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(244, 215, 157, 0.14);
|
||||
color: #f4d79d;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warehouse-current__meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.warehouse-current__meta-pill {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 250, 240, 0.88);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.warehouse-current__notice {
|
||||
margin-top: 18rpx;
|
||||
color: #f7e6bc;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.warehouse-current__action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: #fff6e3;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warehouse-current__action-arrow {
|
||||
color: #f4d79d;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.shipping-detail__header {
|
||||
margin-top: 28rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.shipping-detail__title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.shipping-detail__desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.form-panel--readonly {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.form-panel__readonly-tip {
|
||||
margin-top: 12rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(237, 189, 0, 0.1);
|
||||
color: #c89b00;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.field-box--readonly {
|
||||
background: #f6f6f7;
|
||||
border-color: var(--card-border);
|
||||
}
|
||||
|
||||
.warehouse-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 180;
|
||||
}
|
||||
|
||||
.warehouse-sheet__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 20, 18, 0.38);
|
||||
}
|
||||
|
||||
.warehouse-sheet__panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -12rpx 36rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.warehouse-sheet__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.warehouse-sheet__title {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.warehouse-sheet__desc {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.warehouse-sheet__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 104rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(237, 189, 0, 0.12);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.warehouse-sheet__list {
|
||||
max-height: 58vh;
|
||||
}
|
||||
|
||||
.warehouse-sheet__option {
|
||||
padding: 22rpx 22rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
border: 2rpx solid rgba(219, 206, 183, 0.9);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.warehouse-sheet__option + .warehouse-sheet__option {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.warehouse-sheet__option--selected {
|
||||
border-color: rgba(237, 189, 0, 0.38);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.warehouse-sheet__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.warehouse-sheet__head {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.warehouse-sheet__name {
|
||||
color: var(--color-heading);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.warehouse-sheet__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.warehouse-sheet__badge {
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #f3efe6;
|
||||
color: #7d6540;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warehouse-sheet__badge--accent {
|
||||
background: rgba(237, 189, 0, 0.12);
|
||||
color: #a36816;
|
||||
}
|
||||
|
||||
.warehouse-sheet__state {
|
||||
flex-shrink: 0;
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(237, 189, 0, 0.12);
|
||||
color: var(--color-accent);
|
||||
font-size: 22rpx;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.warehouse-sheet__meta {
|
||||
margin-top: 8rpx;
|
||||
color: var(--color-body);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.warehouse-sheet__reason {
|
||||
margin-top: 12rpx;
|
||||
padding: 14rpx 16rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(237, 189, 0, 0.1);
|
||||
color: #9a6218;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
217
user-app/src/pages/order/supplement.vue
Normal file
217
user-app/src/pages/order/supplement.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appApi, type SupplementDetailData } from "../../api/app";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const detail = ref<SupplementDetailData | null>(null);
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const uploadingItemId = ref(0);
|
||||
|
||||
const completedCount = computed(
|
||||
() => detail.value?.items.filter((item) => item.files.length > 0).length || 0,
|
||||
);
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
async function fetchDetail(orderId: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await appApi.getSupplementDetail(orderId);
|
||||
} catch (error) {
|
||||
showErrorToast(error, "补资料任务加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function previewFiles(files: Array<{ file_url: string }>, current: string) {
|
||||
if (!files.length) return;
|
||||
uni.previewImage({
|
||||
urls: files.map((item) => item.file_url),
|
||||
current,
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseAndUpload(uploadItemId: number) {
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
const filePath = result.tempFilePaths?.[0];
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingItemId.value = uploadItemId;
|
||||
const file = await appApi.uploadSupplementFile({
|
||||
uploadItemId,
|
||||
filePath,
|
||||
});
|
||||
|
||||
const item = detail.value?.items.find((entry) => entry.upload_item_id === uploadItemId);
|
||||
if (item) {
|
||||
item.files.push(file);
|
||||
item.status = "uploaded";
|
||||
}
|
||||
|
||||
showInfoToast("补资料上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "补资料上传失败");
|
||||
} finally {
|
||||
uploadingItemId.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFile(uploadItemId: number, fileId: string) {
|
||||
try {
|
||||
await appApi.deleteSupplementFile(fileId);
|
||||
const item = detail.value?.items.find((entry) => entry.upload_item_id === uploadItemId);
|
||||
if (item) {
|
||||
item.files = item.files.filter((file) => file.file_id !== fileId);
|
||||
item.status = item.files.length > 0 ? "uploaded" : "pending";
|
||||
}
|
||||
showInfoToast("已删除上传文件");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "删除文件失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSupplement() {
|
||||
if (!detail.value) return;
|
||||
|
||||
const missingItem = detail.value.items.find((item) => item.is_required && item.files.length === 0);
|
||||
if (missingItem) {
|
||||
showInfoToast(`请先上传${missingItem.item_name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await withLoading("正在提交补资料", async () => {
|
||||
await appApi.submitSupplement(detail.value!.order_id);
|
||||
});
|
||||
showInfoToast("补资料已提交");
|
||||
uni.redirectTo({
|
||||
url: `/pages/order/detail?id=${detail.value.order_id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorToast(error, "补资料提交失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
const orderId = Number(options?.order_id || 0);
|
||||
if (!orderId) {
|
||||
showInfoToast("订单信息缺失");
|
||||
return;
|
||||
}
|
||||
await fetchDetail(orderId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="detail" class="section-card section-card--soft">
|
||||
<view class="tag tag--warning">补资料任务</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
请补充鉴定资料
|
||||
</view>
|
||||
<view class="section__desc">{{ detail.reason }}</view>
|
||||
<view class="certificate-header__meta">
|
||||
<text class="certificate-meta-chip">订单号 {{ detail.order_no }}</text>
|
||||
<text class="certificate-meta-chip">截止 {{ detail.deadline || "尽快补充" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail" class="section section-card section-card--soft">
|
||||
<view class="section__title">补充任务说明</view>
|
||||
<view class="section__desc">建议先把必传资料传完,再视情况补充辅助资料。提交后订单会重新进入鉴定流程,系统会继续同步新进展。</view>
|
||||
<view class="chip-list" style="margin-top: 18rpx;">
|
||||
<text class="choice-chip choice-chip--selected">{{ completedCount }} 项已完成</text>
|
||||
<text class="choice-chip">{{ detail.items.length }} 项资料任务</text>
|
||||
<text class="choice-chip">提交后重新进入鉴定</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail" class="section progress-panel">
|
||||
<view class="progress-panel__top">
|
||||
<view class="section__title">当前完成进度</view>
|
||||
<view class="progress-panel__value">{{ completedCount }} / {{ detail.items.length }}</view>
|
||||
</view>
|
||||
<view class="progress-panel__hint">请优先完成所有必传资料上传,提交后订单会重新进入鉴定流程。</view>
|
||||
<view class="progress-panel__bar">
|
||||
<view
|
||||
class="progress-panel__fill"
|
||||
:style="{ width: `${detail.items.length ? Math.max(12, (completedCount / detail.items.length) * 100) : 0}%` }"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="notice-card">
|
||||
<view class="notice-card__title">正在加载补资料任务</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在读取鉴定师给您的补充要求。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && !detail" class="notice-card">
|
||||
<view class="notice-card__title">暂无待补资料任务</view>
|
||||
<view class="notice-card__desc">当前订单没有可补交的资料项,您可以返回订单页稍后查看。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail" class="section">
|
||||
<view
|
||||
v-for="item in detail.items"
|
||||
:key="item.upload_item_id"
|
||||
class="task-card section-card"
|
||||
>
|
||||
<view class="task-card__row">
|
||||
<view class="task-card__title">{{ item.item_name }}</view>
|
||||
<text :class="item.files.length ? 'tag tag--success' : item.is_required ? 'tag tag--warning' : 'tag tag--neutral'">
|
||||
{{ item.files.length ? "已上传" : item.is_required ? "待上传" : "选传" }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="task-card__desc">{{ item.guide_text }}</view>
|
||||
<view class="task-card__row" style="margin-top: 12rpx">
|
||||
<text class="info-list__label">已上传</text>
|
||||
<text class="info-list__value">{{ item.files.length }} 张</text>
|
||||
</view>
|
||||
<view v-if="item.files.length" class="task-files">
|
||||
<view v-for="file in item.files" :key="file.file_id" class="task-file">
|
||||
<image
|
||||
class="task-file__img"
|
||||
:src="file.thumbnail_url"
|
||||
mode="aspectFill"
|
||||
@click="previewFiles(item.files, file.file_url)"
|
||||
/>
|
||||
<view class="task-file__remove" @click="removeFile(item.upload_item_id, file.file_id)">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-card__row" style="margin-top: 20rpx">
|
||||
<text class="info-list__label">{{ item.is_required ? "必传资料" : "辅助资料" }}</text>
|
||||
<text class="btn btn--ghost" @click="chooseAndUpload(item.upload_item_id)">
|
||||
{{ uploadingItemId === item.upload_item_id ? "上传中..." : item.files.length ? "继续补充" : "上传资料" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail" class="section section-note">
|
||||
<view class="support-banner__title">提交后会发生什么?</view>
|
||||
<view class="support-banner__desc">系统会将您的补充资料同步给鉴定师,订单重新进入鉴定流程。若资料仍不充分,客服会继续通过消息中心通知您。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail" class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">稍后再传</view>
|
||||
<view :class="['btn', 'btn--primary', submitting ? 'btn--disabled' : '']" @click="submitSupplement">
|
||||
{{ submitting ? "提交中..." : "提交补资料" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
322
user-app/src/pages/report/detail.vue
Normal file
322
user-app/src/pages/report/detail.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appApi, type EvidenceAttachmentAsset, type ReportDetailData } from "../../api/app";
|
||||
import { reportDetailFallback } from "../../mocks/app";
|
||||
import { resolveErrorMessage } from "../../utils/feedback";
|
||||
import { resolveQrImageSource } from "../../utils/qrcode";
|
||||
|
||||
const detail = ref<ReportDetailData>(reportDetailFallback);
|
||||
const downloading = ref(false);
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const qrImageSource = computed(() =>
|
||||
resolveQrImageSource(detail.value.verify_info.verify_qrcode_url, detail.value.verify_info.verify_url),
|
||||
);
|
||||
const imageEvidenceAttachments = computed(() =>
|
||||
detail.value.evidence_attachments.filter((item) => item.file_type === "image"),
|
||||
);
|
||||
const otherEvidenceAttachments = computed(() =>
|
||||
detail.value.evidence_attachments.filter((item) => item.file_type !== "image"),
|
||||
);
|
||||
|
||||
function goVerify() {
|
||||
uni.navigateTo({ url: `/pages/verify/result?report_no=${detail.value.report_header.report_no}` });
|
||||
}
|
||||
|
||||
function previewEvidenceImage(current: string) {
|
||||
if (!imageEvidenceAttachments.value.length) return;
|
||||
uni.previewImage({
|
||||
urls: imageEvidenceAttachments.value.map((item) => item.file_url),
|
||||
current,
|
||||
});
|
||||
}
|
||||
|
||||
function evidenceTypeText(fileType: string) {
|
||||
if (fileType === "video") return "视频";
|
||||
if (fileType === "pdf") return "PDF";
|
||||
if (fileType === "image") return "图片";
|
||||
return "附件";
|
||||
}
|
||||
|
||||
function evidenceDisplayName(item: EvidenceAttachmentAsset, index: number) {
|
||||
return item.name || `${evidenceTypeText(item.file_type)} ${index + 1}`;
|
||||
}
|
||||
|
||||
function openEvidenceAttachment(item: EvidenceAttachmentAsset) {
|
||||
if (item.file_type === "image") {
|
||||
previewEvidenceImage(item.file_url);
|
||||
return;
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
window.open(item.file_url, "_blank");
|
||||
return;
|
||||
// #endif
|
||||
|
||||
if (item.file_type === "pdf") {
|
||||
uni.showLoading({ title: "准备打开" });
|
||||
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" }),
|
||||
});
|
||||
},
|
||||
fail: () => uni.showToast({ title: "附件打开失败", icon: "none" }),
|
||||
complete: () => uni.hideLoading(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const previewMedia = (uni as any).previewMedia;
|
||||
if (typeof previewMedia === "function" && item.file_type === "video") {
|
||||
previewMedia({
|
||||
sources: [{ url: item.file_url, type: "video", poster: item.thumbnail_url || "" }],
|
||||
current: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({ title: "当前附件类型暂不支持预览", icon: "none" });
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
const pdfUrl = detail.value.file_info?.pdf_url;
|
||||
if (!pdfUrl) {
|
||||
uni.showToast({
|
||||
title: "报告文件暂未就绪",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
window.open(pdfUrl, "_blank");
|
||||
return;
|
||||
// #endif
|
||||
|
||||
downloading.value = true;
|
||||
uni.showLoading({ title: "准备下载" });
|
||||
uni.downloadFile({
|
||||
url: pdfUrl,
|
||||
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",
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({
|
||||
title: "报告下载失败",
|
||||
icon: "none",
|
||||
});
|
||||
},
|
||||
complete: () => {
|
||||
downloading.value = false;
|
||||
uni.hideLoading();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
const id = Number(options?.id || 0);
|
||||
const reportNo = String(options?.report_no || "");
|
||||
if (!id && !reportNo) {
|
||||
loadError.value = "缺少报告编号,无法查看详情。";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await appApi.getReportDetail({
|
||||
id: id || undefined,
|
||||
report_no: reportNo || undefined,
|
||||
});
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("report detail fallback", error);
|
||||
loadError.value = resolveErrorMessage(error, "报告详情加载失败,请稍后重试。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载报告详情</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步报告正文、验真信息与 PDF 文件。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">报告详情加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="section-card certificate-header">
|
||||
<view class="certificate-header__top">
|
||||
<text class="tag tag--success">有效</text>
|
||||
<text class="certificate-meta-chip">{{ detail.report_header.institution_name }}</text>
|
||||
</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
{{ detail.report_header.report_title }}
|
||||
</view>
|
||||
<view class="section__desc">正式结果凭证,支持编号与二维码验真。</view>
|
||||
<view class="certificate-header__meta">
|
||||
<text class="certificate-meta-chip">报告编号 {{ detail.report_header.report_no }}</text>
|
||||
<text class="certificate-meta-chip">出具日期 {{ detail.report_header.publish_time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section report-result report-result--certificate">
|
||||
<text class="report-result__seal">已鉴定确认</text>
|
||||
<view class="report-result__title">鉴定结论</view>
|
||||
<view class="report-result__value" style="color: var(--color-status-success);">{{ detail.result_info.result_text }}</view>
|
||||
<view class="report-result__desc">{{ detail.result_info.result_desc }}</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">商品信息</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">商品名称</text>
|
||||
<text class="report-meta__value">{{ detail.product_info.product_name }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">品类 / 品牌</text>
|
||||
<text class="report-meta__value">{{ detail.product_info.category_name }} / {{ detail.product_info.brand_name }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">颜色 / 规格</text>
|
||||
<text class="report-meta__value">{{ detail.product_info.color }} / {{ detail.product_info.size_spec }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">鉴定信息</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">服务类型</text>
|
||||
<text class="report-meta__value">{{ detail.appraisal_info.service_provider === 'zhongjian' ? '中检鉴定' : '实物鉴定' }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">鉴定机构</text>
|
||||
<text class="report-meta__value">{{ detail.appraisal_info.institution_name }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">鉴定师</text>
|
||||
<text class="report-meta__value">{{ detail.appraisal_info.appraiser_name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">评级与估值</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">成色评级</text>
|
||||
<text class="report-meta__value">{{ detail.valuation_info.condition_grade }} 级</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">市场估值</text>
|
||||
<text class="report-meta__value">¥{{ detail.valuation_info.valuation_min }} - ¥{{ detail.valuation_info.valuation_max }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">报告凭证</view>
|
||||
<view class="credential-box">
|
||||
<view class="credential-box__qr">
|
||||
<image v-if="qrImageSource" class="credential-box__qr-image" :src="qrImageSource" mode="aspectFit" />
|
||||
<text v-else class="credential-box__qr-empty">验真二维码</text>
|
||||
</view>
|
||||
<view class="credential-box__body">
|
||||
<text class="tag tag--accent">{{ detail.verify_info.report_no }}</text>
|
||||
<view class="section__desc">本报告支持扫码或输入编号验真,请以验真页面结果为准。</view>
|
||||
<view style="margin-top: 16rpx">
|
||||
<text class="btn btn--ghost" @click="goVerify">去验真</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.evidence_attachments.length" class="section section-card">
|
||||
<view class="section__title">证据附件</view>
|
||||
<view class="section__desc">以下附件为本次报告留存的证据材料,可点击查看原图、视频或 PDF。</view>
|
||||
|
||||
<view v-if="imageEvidenceAttachments.length" class="task-files" style="margin-top: 20rpx;">
|
||||
<view
|
||||
v-for="item in imageEvidenceAttachments"
|
||||
:key="item.file_id"
|
||||
class="task-file"
|
||||
@click="previewEvidenceImage(item.file_url)"
|
||||
>
|
||||
<image class="task-file__img" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="otherEvidenceAttachments.length" style="margin-top: 20rpx;">
|
||||
<view
|
||||
v-for="(item, index) in otherEvidenceAttachments"
|
||||
:key="item.file_id"
|
||||
class="info-list__row"
|
||||
@click="openEvidenceAttachment(item)"
|
||||
>
|
||||
<text class="info-list__label">{{ evidenceDisplayName(item, index) }}</text>
|
||||
<text class="info-list__value">{{ evidenceTypeText(item.file_type) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-note">
|
||||
<view class="section__title">说明</view>
|
||||
<view class="section__desc">
|
||||
{{ detail.risk_notice_text }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="downloadPdf">{{ downloading ? "下载中..." : "下载 PDF" }}</view>
|
||||
<view class="btn btn--primary" @click="goVerify">去验真</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.credential-box__qr {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.credential-box__qr-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.credential-box__qr-empty {
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
558
user-app/src/pages/report/index.vue
Normal file
558
user-app/src/pages/report/index.vue
Normal file
@@ -0,0 +1,558 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type ReportListItem } from "../../api/app";
|
||||
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
|
||||
import { showErrorToast } from "../../utils/feedback";
|
||||
import { isLoggedIn, redirectToLogin } from "../../utils/auth";
|
||||
|
||||
const reports = ref<ReportListItem[]>([]);
|
||||
const privacyMode = ref(getPrivacyMode());
|
||||
const reportHeroBackground = ref("");
|
||||
const defaultReportHeroBackground = "/static/report/report-reference.jpg";
|
||||
|
||||
const reportStats = computed(() => ({
|
||||
total: reports.value.length,
|
||||
published: reports.value.filter((item) => item.report_id).length,
|
||||
pending: reports.value.filter((item) => !item.report_id).length,
|
||||
}));
|
||||
|
||||
const reportHeroStyle = computed(() => ({
|
||||
backgroundImage: `url("${reportHeroBackground.value || defaultReportHeroBackground}")`,
|
||||
}));
|
||||
|
||||
const emptyState = computed(() => ({
|
||||
title: "暂无鉴定报告",
|
||||
desc: "正式报告生成后,您可以在这里查看结果、下载PDF,并继续进入验真。",
|
||||
}));
|
||||
|
||||
function openReport(item: ReportListItem) {
|
||||
if (!item.report_id || !item.report_no) {
|
||||
uni.navigateTo({ url: `/pages/order/detail?id=${item.order_id}` });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/report/detail?report_no=${encodeURIComponent(item.report_no)}` });
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.switchTab({ url: "/pages/home/index" });
|
||||
}
|
||||
|
||||
function goStartAppraisal() {
|
||||
uni.navigateTo({ url: "/pages/appraisal/service" });
|
||||
}
|
||||
|
||||
function goHelp() {
|
||||
uni.navigateTo({ url: "/pages/help/index" });
|
||||
}
|
||||
|
||||
async function fetchPageVisuals() {
|
||||
try {
|
||||
const data = await appApi.getPageVisuals();
|
||||
reportHeroBackground.value = data.report_background_image_url || "";
|
||||
} catch (error) {
|
||||
console.warn("report page visuals fallback", error);
|
||||
}
|
||||
}
|
||||
|
||||
onShow(async () => {
|
||||
privacyMode.value = getPrivacyMode();
|
||||
void fetchPageVisuals();
|
||||
if (!isLoggedIn()) {
|
||||
reports.value = [];
|
||||
redirectToLogin("/pages/report/index");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await appApi.getReports();
|
||||
reports.value = data.list;
|
||||
} catch (error) {
|
||||
reports.value = [];
|
||||
showErrorToast(error, "报告加载失败");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="report-page">
|
||||
<view :class="['report-hero', reports.length === 0 ? 'report-hero--empty' : 'report-hero--list']">
|
||||
<view class="report-hero__bg" :style="reportHeroStyle"></view>
|
||||
<view class="report-nav">
|
||||
<view class="report-nav__home" @click="goHome">
|
||||
<view class="report-nav__home-roof"></view>
|
||||
<view class="report-nav__home-body"></view>
|
||||
</view>
|
||||
<view class="report-nav__title">报告中心</view>
|
||||
<view class="report-nav__capsule">
|
||||
<text class="report-nav__dots">•••</text>
|
||||
<view class="report-nav__divider"></view>
|
||||
<view class="report-nav__circle"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="report-hero__content">
|
||||
<view class="report-hero__title">报告中心</view>
|
||||
<view class="report-hero__desc">正式报告会自动归档到这里,支持查看结果、下载 PDF 和继续验真。</view>
|
||||
|
||||
<view class="report-stat-grid">
|
||||
<view class="report-stat-card">
|
||||
<view class="report-stat-card__value">{{ reportStats.total }}</view>
|
||||
<view class="report-stat-card__label">报告总数</view>
|
||||
</view>
|
||||
<view class="report-stat-card">
|
||||
<view class="report-stat-card__value">{{ reportStats.published }}</view>
|
||||
<view class="report-stat-card__label">已出报告</view>
|
||||
</view>
|
||||
<view class="report-stat-card">
|
||||
<view class="report-stat-card__value">{{ reportStats.pending }}</view>
|
||||
<view class="report-stat-card__label">待生成</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="reports.length === 0" class="report-empty-state">
|
||||
<view class="report-empty-state__title">{{ emptyState.title }}</view>
|
||||
<view class="report-empty-state__desc">{{ emptyState.desc }}</view>
|
||||
<view class="report-empty-state__actions">
|
||||
<view class="report-empty-state__button report-empty-state__button--primary" @click="goStartAppraisal">发起鉴定</view>
|
||||
<view class="report-empty-state__button report-empty-state__button--secondary" @click="goHelp">查看帮助</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="report-list">
|
||||
<view
|
||||
v-for="item in reports"
|
||||
:key="item.report_id || item.order_id"
|
||||
class="report-card"
|
||||
@click="openReport(item)"
|
||||
>
|
||||
<view class="report-card__top">
|
||||
<view class="report-card__thumb">
|
||||
<image v-if="item.product_cover" class="report-card__image" :src="item.product_cover" mode="aspectFill" />
|
||||
<view v-else class="report-card__placeholder">
|
||||
<view class="report-card__placeholder-line"></view>
|
||||
<view class="report-card__placeholder-search"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="report-card__content">
|
||||
<view class="report-card__title">{{ item.product_name }}</view>
|
||||
<view class="report-card__no">
|
||||
报告编号:{{ item.report_no ? maskOrderNo(item.report_no, privacyMode) : "待生成" }}
|
||||
</view>
|
||||
<view class="report-card__institution">出具机构:{{ item.institution_name || "待确认" }}</view>
|
||||
</view>
|
||||
<text
|
||||
class="report-card__status"
|
||||
:class="item.report_id ? 'report-card__status--success' : 'report-card__status--info'"
|
||||
>
|
||||
{{ item.report_id ? "已出报告" : "待生成" }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="report-card__footer">
|
||||
<view class="report-card__result">{{ item.result_text || "等待鉴定结果" }}</view>
|
||||
<view class="report-card__action">{{ item.report_id ? "查看报告" : "查看订单" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.report-page {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
background: #f2f2f4;
|
||||
color: #2d2d2f;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.report-page,
|
||||
.report-page view,
|
||||
.report-page text {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.report-hero {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
padding: 88rpx 32rpx 0;
|
||||
}
|
||||
|
||||
.report-hero--empty {
|
||||
height: 900rpx;
|
||||
}
|
||||
|
||||
.report-hero--list {
|
||||
height: 900rpx;
|
||||
}
|
||||
|
||||
.report-hero__bg {
|
||||
position: absolute;
|
||||
inset: -18rpx;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top center;
|
||||
background-size: 100vw auto;
|
||||
filter: blur(8rpx) saturate(1.04);
|
||||
opacity: 0.76;
|
||||
transform: scale(1.04);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.report-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(242, 242, 244, 0.18) 45%, rgba(242, 242, 244, 0.78) 76%, #f2f2f4 96%),
|
||||
linear-gradient(0deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0.06));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.report-nav,
|
||||
.report-hero__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.report-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 76rpx;
|
||||
}
|
||||
|
||||
.report-nav__title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
color: #272729;
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-nav__home {
|
||||
position: relative;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.report-nav__home-roof {
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: 7rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-top: 4rpx solid #2a2a2c;
|
||||
border-left: 4rpx solid #2a2a2c;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.report-nav__home-body {
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
bottom: 8rpx;
|
||||
width: 34rpx;
|
||||
height: 30rpx;
|
||||
border: 4rpx solid #2a2a2c;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 8rpx 8rpx;
|
||||
background: linear-gradient(135deg, transparent 0 36%, #f0c000 36% 100%);
|
||||
}
|
||||
|
||||
.report-nav__capsule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 174rpx;
|
||||
height: 64rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 34rpx;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: blur(12rpx);
|
||||
}
|
||||
|
||||
.report-nav__dots {
|
||||
color: #050505;
|
||||
font-size: 36rpx;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.report-nav__divider {
|
||||
width: 1rpx;
|
||||
height: 34rpx;
|
||||
background: rgba(20, 20, 20, 0.68);
|
||||
}
|
||||
|
||||
.report-nav__circle {
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
border: 7rpx solid #070707;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.report-hero__content {
|
||||
margin-top: 260rpx;
|
||||
}
|
||||
|
||||
.report-hero__title {
|
||||
color: #262628;
|
||||
font-size: 70rpx;
|
||||
line-height: 1.08;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.report-hero__desc {
|
||||
width: 650rpx;
|
||||
max-width: 100%;
|
||||
margin-top: 28rpx;
|
||||
color: #666;
|
||||
font-size: 29rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.report-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 42rpx;
|
||||
margin: 26rpx 22rpx 0;
|
||||
}
|
||||
|
||||
.report-stat-card {
|
||||
height: 176rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
text-align: center;
|
||||
box-shadow: 0 10rpx 24rpx rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.report-stat-card__value {
|
||||
margin-top: 35rpx;
|
||||
color: #2b2b2d;
|
||||
font-size: 68rpx;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.report-stat-card__label {
|
||||
margin-top: 18rpx;
|
||||
color: #686868;
|
||||
font-size: 25rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.report-empty-state {
|
||||
margin: 240rpx 32rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-empty-state__title {
|
||||
color: #202022;
|
||||
font-size: 40rpx;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.report-empty-state__desc {
|
||||
width: 560rpx;
|
||||
max-width: 100%;
|
||||
margin: 28rpx auto 0;
|
||||
color: #818181;
|
||||
font-size: 29rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.report-empty-state__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 38rpx;
|
||||
margin-top: 50rpx;
|
||||
}
|
||||
|
||||
.report-empty-state__button {
|
||||
min-width: 0;
|
||||
height: 68rpx;
|
||||
border-radius: 36rpx;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
line-height: 68rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-empty-state__button--primary {
|
||||
background: #edbd00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.report-empty-state__button--secondary {
|
||||
background: #fff;
|
||||
color: #29292b;
|
||||
}
|
||||
|
||||
.report-list {
|
||||
display: grid;
|
||||
gap: 22rpx;
|
||||
margin: -22rpx 32rpx 0;
|
||||
padding-bottom: 36rpx;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 12rpx 26rpx rgba(0, 0, 0, 0.035);
|
||||
}
|
||||
|
||||
.report-card__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.report-card__thumb {
|
||||
flex: 0 0 104rpx;
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 12rpx;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.report-card__image {
|
||||
display: block;
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
}
|
||||
|
||||
.report-card__placeholder {
|
||||
position: relative;
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.report-card__placeholder-line {
|
||||
position: absolute;
|
||||
left: 31rpx;
|
||||
top: 22rpx;
|
||||
width: 36rpx;
|
||||
height: 52rpx;
|
||||
border: 5rpx solid #2e2e30;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.report-card__placeholder-search {
|
||||
position: absolute;
|
||||
right: 18rpx;
|
||||
bottom: 18rpx;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border: 5rpx solid #edbd00;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.report-card__placeholder-search::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -10rpx;
|
||||
bottom: -6rpx;
|
||||
width: 18rpx;
|
||||
height: 5rpx;
|
||||
border-radius: 4rpx;
|
||||
background: #edbd00;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.report-card__content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.report-card__title {
|
||||
color: #242426;
|
||||
font-size: 31rpx;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.report-card__no,
|
||||
.report-card__institution {
|
||||
margin-top: 10rpx;
|
||||
color: #777;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.report-card__status {
|
||||
flex-shrink: 0;
|
||||
height: 42rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: 22rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.report-card__status--success {
|
||||
background: #eaf7ef;
|
||||
color: #2d7652;
|
||||
}
|
||||
|
||||
.report-card__status--info {
|
||||
background: #edf4ff;
|
||||
color: #28669f;
|
||||
}
|
||||
|
||||
.report-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.report-card__result {
|
||||
min-width: 0;
|
||||
color: #777;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.report-card__action {
|
||||
flex-shrink: 0;
|
||||
min-width: 126rpx;
|
||||
height: 48rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 25rpx;
|
||||
background: #f1bd00;
|
||||
color: #fff;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.report-stat-grid {
|
||||
gap: 22rpx;
|
||||
margin-left: 12rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.report-empty-state__actions {
|
||||
gap: 22rpx;
|
||||
}
|
||||
|
||||
.report-card__top {
|
||||
gap: 14rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
237
user-app/src/pages/settings/index.vue
Normal file
237
user-app/src/pages/settings/index.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type SettingsData } from "../../api/app";
|
||||
import { authApi } from "../../api/auth";
|
||||
import { settingsFallback } from "../../mocks/app";
|
||||
import { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
import { logoutAndRedirect } from "../../utils/auth";
|
||||
import { setPrivacyMode } from "../../utils/privacy";
|
||||
|
||||
const detail = ref<SettingsData>(settingsFallback);
|
||||
const saving = ref(false);
|
||||
const nickname = ref(settingsFallback.profile_info.nickname);
|
||||
const appraisalStore = useAppraisalStore();
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
function openLegal(url: string) {
|
||||
if (!url) return;
|
||||
uni.navigateTo({ url });
|
||||
}
|
||||
|
||||
function openPasswordPage() {
|
||||
const hasPassword = detail.value.profile_info.password_set ? "1" : "0";
|
||||
uni.navigateTo({ url: `/pages/settings/password?has_password=${hasPassword}` });
|
||||
}
|
||||
|
||||
function handlePreferenceChange(key: keyof SettingsData["preferences"], value: boolean) {
|
||||
detail.value.preferences[key] = value;
|
||||
}
|
||||
|
||||
function handlePreferenceEvent(key: keyof SettingsData["preferences"], event: any) {
|
||||
handlePreferenceChange(key, !!event?.detail?.value);
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
loading.value = true;
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
const data = await appApi.getSettings();
|
||||
detail.value = data;
|
||||
nickname.value = data.profile_info.nickname;
|
||||
setPrivacyMode(data.preferences.privacy_mode);
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("settings fallback", error);
|
||||
if (!pageReady.value) {
|
||||
loadError.value = resolveErrorMessage(error, "设置加载失败,请稍后重试。");
|
||||
} else {
|
||||
showErrorToast(error, "设置加载失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
if (!nickname.value.trim()) {
|
||||
showInfoToast("昵称不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const data = await withLoading("正在保存设置", async () =>
|
||||
appApi.saveSettings({
|
||||
nickname: nickname.value.trim(),
|
||||
preferences: detail.value.preferences,
|
||||
}),
|
||||
);
|
||||
detail.value = data;
|
||||
nickname.value = data.profile_info.nickname;
|
||||
setPrivacyMode(data.preferences.privacy_mode);
|
||||
showInfoToast("设置已保存");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "设置保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch (error) {
|
||||
console.warn("logout failed", error);
|
||||
} finally {
|
||||
appraisalStore.resetForNewFlow();
|
||||
logoutAndRedirect();
|
||||
}
|
||||
}
|
||||
|
||||
onShow(fetchSettings);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载设置</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步账号资料与通知偏好。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">设置加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="fetchSettings">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="section-card">
|
||||
<view class="tag tag--accent">设置中心</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
账号与通知偏好
|
||||
</view>
|
||||
<view class="section__desc">您可以在这里维护昵称、查看账号状态,并选择希望接收的服务通知类型。</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">账号资料</view>
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">昵称</view>
|
||||
<view class="field-box">
|
||||
<input v-model="nickname" class="field-input" maxlength="20" placeholder="请输入昵称" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="report-meta__row" style="margin-top: 20rpx">
|
||||
<text class="report-meta__label">手机号</text>
|
||||
<text class="report-meta__value">{{ detail.profile_info.mobile }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">账号状态</text>
|
||||
<text class="report-meta__value">{{ detail.profile_info.status_text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">登录与安全</view>
|
||||
<view class="section__desc">可为当前账号设置或修改登录密码,用于手机号 + 密码登录。</view>
|
||||
<view class="info-list__row" style="margin-top: 20rpx" @click="openPasswordPage">
|
||||
<text class="info-list__label">登录密码</text>
|
||||
<text class="info-list__value">{{ detail.profile_info.password_set ? "已设置,去修改" : "未设置,去创建" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-panel__title">通知偏好</view>
|
||||
<view class="form-panel__desc">关闭某类提醒后,不会影响订单和工单本身处理,只会减少消息提醒。</view>
|
||||
|
||||
<view class="setting-row">
|
||||
<view>
|
||||
<view class="form-group__label">订单通知</view>
|
||||
<view class="form-group__hint">下单成功、运单提交等订单相关提醒</view>
|
||||
</view>
|
||||
<switch :checked="detail.preferences.notify_order" color="#edbd00" @change="handlePreferenceEvent('notify_order', $event)" />
|
||||
</view>
|
||||
|
||||
<view class="setting-row">
|
||||
<view>
|
||||
<view class="form-group__label">报告通知</view>
|
||||
<view class="form-group__hint">报告出具、验真与下载相关提醒</view>
|
||||
</view>
|
||||
<switch :checked="detail.preferences.notify_report" color="#edbd00" @change="handlePreferenceEvent('notify_report', $event)" />
|
||||
</view>
|
||||
|
||||
<view class="setting-row">
|
||||
<view>
|
||||
<view class="form-group__label">补资料通知</view>
|
||||
<view class="form-group__hint">鉴定师发起补资料后的提醒</view>
|
||||
</view>
|
||||
<switch :checked="detail.preferences.notify_supplement" color="#edbd00" @change="handlePreferenceEvent('notify_supplement', $event)" />
|
||||
</view>
|
||||
|
||||
<view class="setting-row">
|
||||
<view>
|
||||
<view class="form-group__label">工单通知</view>
|
||||
<view class="form-group__hint">客服回复、工单状态变化相关提醒</view>
|
||||
</view>
|
||||
<switch :checked="detail.preferences.notify_ticket" color="#edbd00" @change="handlePreferenceEvent('notify_ticket', $event)" />
|
||||
</view>
|
||||
|
||||
<view class="setting-row">
|
||||
<view>
|
||||
<view class="form-group__label">营销与活动提醒</view>
|
||||
<view class="form-group__hint">接收活动、权益和服务升级通知</view>
|
||||
</view>
|
||||
<switch :checked="detail.preferences.marketing_notify" color="#edbd00" @change="handlePreferenceEvent('marketing_notify', $event)" />
|
||||
</view>
|
||||
|
||||
<view class="setting-row">
|
||||
<view>
|
||||
<view class="form-group__label">隐私模式</view>
|
||||
<view class="form-group__hint">减少在列表页展示部分敏感账号信息</view>
|
||||
</view>
|
||||
<switch :checked="detail.preferences.privacy_mode" color="#edbd00" @change="handlePreferenceEvent('privacy_mode', $event)" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">说明与协议</view>
|
||||
<view
|
||||
v-for="item in detail.legal_entries"
|
||||
:key="item.code"
|
||||
class="info-list__row"
|
||||
@click="openLegal(item.target_url)"
|
||||
>
|
||||
<text class="info-list__label">{{ item.title }}</text>
|
||||
<text class="info-list__value">{{ item.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">登录管理</view>
|
||||
<view class="section__desc">当前设备退出后,需要重新使用手机号验证码或密码登录。</view>
|
||||
<view style="margin-top: 20rpx;">
|
||||
<text class="btn btn--secondary" @click="handleLogout">退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">返回</view>
|
||||
<view :class="['btn', 'btn--primary', saving ? 'btn--disabled' : '']" @click="saveSettings">
|
||||
{{ saving ? "保存中..." : "保存设置" }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
120
user-app/src/pages/settings/password.vue
Normal file
120
user-app/src/pages/settings/password.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { authApi } from "../../api/auth";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const saving = ref(false);
|
||||
const hasPassword = ref(false);
|
||||
const form = reactive({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => (hasPassword.value ? "修改登录密码" : "设置登录密码"));
|
||||
const pageDesc = computed(() =>
|
||||
hasPassword.value
|
||||
? "修改后,下次可以直接使用手机号和新密码登录。"
|
||||
: "设置完成后,当前账号可直接使用手机号和密码登录。",
|
||||
);
|
||||
|
||||
function validateForm() {
|
||||
if (hasPassword.value && !form.currentPassword.trim()) {
|
||||
showInfoToast("请输入当前密码");
|
||||
return false;
|
||||
}
|
||||
if (!form.newPassword.trim() || !form.confirmPassword.trim()) {
|
||||
showInfoToast("请完整填写新密码");
|
||||
return false;
|
||||
}
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
showInfoToast("两次输入的新密码不一致");
|
||||
return false;
|
||||
}
|
||||
if (form.newPassword.length < 8) {
|
||||
showInfoToast("密码长度不能少于 8 位");
|
||||
return false;
|
||||
}
|
||||
if (!/[A-Za-z]/.test(form.newPassword) || !/\d/.test(form.newPassword)) {
|
||||
showInfoToast("密码需同时包含字母和数字");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
if (saving.value) return;
|
||||
if (!validateForm()) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await withLoading("正在保存密码", async () =>
|
||||
authApi.savePassword({
|
||||
current_password: hasPassword.value ? form.currentPassword.trim() : undefined,
|
||||
new_password: form.newPassword.trim(),
|
||||
confirm_password: form.confirmPassword.trim(),
|
||||
}),
|
||||
);
|
||||
showInfoToast(hasPassword.value ? "密码已更新" : "密码已设置");
|
||||
uni.navigateBack();
|
||||
} catch (error) {
|
||||
showErrorToast(error, hasPassword.value ? "密码修改失败" : "密码设置失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
hasPassword.value = String(options?.has_password || "0") === "1";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view class="section-card">
|
||||
<view class="tag tag--accent">登录与安全</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
{{ pageTitle }}
|
||||
</view>
|
||||
<view class="section__desc">{{ pageDesc }}</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-panel__title">密码信息</view>
|
||||
<view v-if="hasPassword" class="form-group">
|
||||
<view class="form-group__label">当前密码</view>
|
||||
<view class="field-box">
|
||||
<input v-model="form.currentPassword" class="field-input" password placeholder="请输入当前密码" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">新密码</view>
|
||||
<view class="field-box">
|
||||
<input v-model="form.newPassword" class="field-input" password placeholder="请输入新密码" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">确认新密码</view>
|
||||
<view class="field-box">
|
||||
<input v-model="form.confirmPassword" class="field-input" password placeholder="请再次输入新密码" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group__hint">密码需至少 8 位,并同时包含字母和数字。</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">返回</view>
|
||||
<view :class="['btn', 'btn--primary', saving ? 'btn--disabled' : '']" @click="savePassword">
|
||||
{{ saving ? "保存中..." : hasPassword ? "更新密码" : "设置密码" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
237
user-app/src/pages/support/create.vue
Normal file
237
user-app/src/pages/support/create.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appApi, type OrderDetailData, type TicketAttachmentAsset, type TicketTypeOption } from "../../api/app";
|
||||
import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const orderDetail = ref<OrderDetailData | null>(null);
|
||||
const orderId = ref(0);
|
||||
const reportId = ref(0);
|
||||
const ticketType = ref("order_issue");
|
||||
const title = ref("");
|
||||
const content = ref("");
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const attachments = ref<TicketAttachmentAsset[]>([]);
|
||||
const orderLoading = ref(false);
|
||||
const orderLoadError = ref("");
|
||||
const ticketTypes = ref<TicketTypeOption[]>([]);
|
||||
|
||||
const selectedTypeHint = computed(
|
||||
() => ticketTypes.value.find((item) => item.code === ticketType.value)?.hint || "",
|
||||
);
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
function previewAttachments(current: string) {
|
||||
if (!attachments.value.length) return;
|
||||
uni.previewImage({
|
||||
urls: attachments.value.map((item) => item.file_url),
|
||||
current,
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseAttachments() {
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 3,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
if (!result.tempFilePaths?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await appApi.uploadTicketFile(filePath);
|
||||
attachments.value.push(asset);
|
||||
}
|
||||
showInfoToast("附件上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "附件上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAttachment(fileUrl: string) {
|
||||
try {
|
||||
await appApi.deleteTicketFile(fileUrl);
|
||||
attachments.value = attachments.value.filter((item) => item.file_url !== fileUrl);
|
||||
showInfoToast("附件已删除");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "附件删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTicket() {
|
||||
if (!title.value.trim()) {
|
||||
showInfoToast("请先填写工单标题");
|
||||
return;
|
||||
}
|
||||
if (!content.value.trim() && !attachments.value.length) {
|
||||
showInfoToast("请先填写问题描述或上传附件");
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const response = await withLoading("正在提交工单", async () =>
|
||||
appApi.createTicket({
|
||||
ticket_type: ticketType.value,
|
||||
title: title.value.trim(),
|
||||
content: content.value.trim(),
|
||||
order_id: orderId.value || undefined,
|
||||
report_id: reportId.value || undefined,
|
||||
attachments: attachments.value,
|
||||
}),
|
||||
);
|
||||
showInfoToast("工单已提交");
|
||||
uni.redirectTo({ url: `/pages/support/detail?id=${response.ticket_id}` });
|
||||
} catch (error) {
|
||||
showErrorToast(error, "工单提交失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
try {
|
||||
const meta = await appApi.getTicketMeta();
|
||||
ticketTypes.value = meta.ticket_types;
|
||||
} catch (error) {
|
||||
console.warn("ticket meta load failed", error);
|
||||
}
|
||||
|
||||
ticketType.value = String(options?.ticket_type || ticketType.value);
|
||||
title.value = String(options?.prefill_title || "");
|
||||
orderId.value = Number(options?.order_id || 0);
|
||||
reportId.value = Number(options?.report_id || 0);
|
||||
|
||||
if (!orderId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
orderLoading.value = true;
|
||||
orderLoadError.value = "";
|
||||
try {
|
||||
orderDetail.value = await appApi.getOrderDetail(orderId.value);
|
||||
} catch (error) {
|
||||
console.warn("order detail fallback", error);
|
||||
orderDetail.value = null;
|
||||
orderLoadError.value = resolveErrorMessage(error, "关联订单读取失败,当前仍可继续提交工单。");
|
||||
} finally {
|
||||
orderLoading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view class="section-card">
|
||||
<view class="tag tag--accent">发起工单</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
提交客服问题
|
||||
</view>
|
||||
<view class="section__desc">请尽量描述清楚当前问题、出现时间和您的预期,我们会按工单类型尽快分流处理。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="ticketType" class="section section-card section-card--soft">
|
||||
<view class="section__title">当前选择的问题类型</view>
|
||||
<view class="section__desc">{{ ticketTypes.find((item) => item.code === ticketType)?.title || "未选择类型" }}</view>
|
||||
<view class="form-group__hint" style="margin-top: 12rpx;">{{ selectedTypeHint }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="orderId && orderLoading" class="section notice-card">
|
||||
<view class="notice-card__title">正在读取关联订单</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步当前工单对应的订单信息。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="orderId && orderLoadError" class="section notice-card">
|
||||
<view class="notice-card__title">关联订单读取失败</view>
|
||||
<view class="notice-card__desc">{{ orderLoadError }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="orderId && orderDetail" class="section section-card section-card--soft">
|
||||
<view class="section__title">当前关联订单</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">订单号</text>
|
||||
<text class="report-meta__value">{{ orderDetail.order_info.order_no }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">当前状态</text>
|
||||
<text class="report-meta__value">{{ orderDetail.order_info.display_status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-panel__title">问题类型</view>
|
||||
<view class="form-panel__desc">先选择问题类别,客服会按对应队列更快跟进。</view>
|
||||
<view class="chip-list">
|
||||
<view
|
||||
v-for="item in ticketTypes"
|
||||
:key="item.code"
|
||||
:class="['choice-chip', ticketType === item.code ? 'choice-chip--selected' : '']"
|
||||
@click="ticketType = item.code"
|
||||
>
|
||||
{{ item.title }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-group__hint">{{ selectedTypeHint }}</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">工单标题</view>
|
||||
<view class="field-box">
|
||||
<input v-model="title" class="field-input" maxlength="40" placeholder="例如:补资料上传后仍显示待处理" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">问题描述</view>
|
||||
<view class="textarea-box">
|
||||
<textarea
|
||||
v-model="content"
|
||||
class="textarea-box__input"
|
||||
maxlength="500"
|
||||
placeholder="请描述问题现象、出现时间、您已尝试的操作,以及希望客服协助的内容。"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-group__hint">建议尽量写清楚页面位置、操作步骤和报错现象,能帮助客服更快定位。</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-group__label">补充截图</view>
|
||||
<view class="task-card__desc">可上传订单截图、报错提示或页面截图,帮助客服更快定位。</view>
|
||||
<view v-if="attachments.length" class="task-files">
|
||||
<view v-for="item in attachments" :key="item.file_id" class="task-file">
|
||||
<image class="task-file__img" :src="item.thumbnail_url" mode="aspectFill" @click="previewAttachments(item.file_url)" />
|
||||
<view class="task-file__remove" @click="removeAttachment(item.file_url)">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-card__row" style="margin-top: 20rpx">
|
||||
<text class="info-list__label">已上传 {{ attachments.length }} 张</text>
|
||||
<text class="btn btn--ghost" @click="chooseAttachments">
|
||||
{{ uploading ? "上传中..." : "上传附件" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-note">
|
||||
<view class="support-banner__title">处理说明</view>
|
||||
<view class="support-banner__desc">工单提交后,您可以在工单详情里查看客服回复。涉及订单问题时,建议尽量关联具体订单。</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="goBack">取消</view>
|
||||
<view :class="['btn', 'btn--primary', submitting ? 'btn--disabled' : '']" @click="submitTicket">
|
||||
{{ submitting ? "提交中..." : "提交工单" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
247
user-app/src/pages/support/detail.vue
Normal file
247
user-app/src/pages/support/detail.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type TicketAttachmentAsset, type TicketStatusOption, type UserTicketDetailData } from "../../api/app";
|
||||
import { ticketDetailFallback } from "../../mocks/app";
|
||||
import { resolveErrorMessage, showErrorToast, showInfoToast } from "../../utils/feedback";
|
||||
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
|
||||
|
||||
const detail = ref<UserTicketDetailData>(ticketDetailFallback);
|
||||
const ticketId = ref(0);
|
||||
const replyContent = ref("");
|
||||
const replying = ref(false);
|
||||
const uploading = ref(false);
|
||||
const replyAttachments = ref<TicketAttachmentAsset[]>([]);
|
||||
const privacyMode = ref(getPrivacyMode());
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const ticketStatuses = ref<TicketStatusOption[]>([]);
|
||||
|
||||
const currentStatusDesc = computed(
|
||||
() => ticketStatuses.value.find((item) => item.code === detail.value.ticket_info.status)?.desc || "",
|
||||
);
|
||||
|
||||
function goOrderDetail() {
|
||||
if (!detail.value.order_info) return;
|
||||
uni.navigateTo({ url: `/pages/order/detail?id=${detail.value.order_info.order_id}` });
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!ticketId.value) return;
|
||||
loading.value = true;
|
||||
privacyMode.value = getPrivacyMode();
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
const [detailResult, metaResult] = await Promise.allSettled([
|
||||
appApi.getTicketDetail(ticketId.value),
|
||||
appApi.getTicketMeta(),
|
||||
]);
|
||||
|
||||
if (detailResult.status !== "fulfilled") {
|
||||
throw detailResult.reason;
|
||||
}
|
||||
|
||||
if (metaResult.status === "fulfilled") {
|
||||
ticketStatuses.value = metaResult.value.ticket_statuses;
|
||||
} else {
|
||||
console.warn("ticket meta load failed", metaResult.reason);
|
||||
}
|
||||
|
||||
detail.value = detailResult.value;
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("ticket detail fallback", error);
|
||||
if (!pageReady.value) {
|
||||
loadError.value = resolveErrorMessage(error, "工单详情加载失败,请稍后重试。");
|
||||
} else {
|
||||
showErrorToast(error, "工单详情刷新失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReply() {
|
||||
if (!replyContent.value.trim() && !replyAttachments.value.length) {
|
||||
showInfoToast("请输入内容或上传附件");
|
||||
return;
|
||||
}
|
||||
|
||||
replying.value = true;
|
||||
try {
|
||||
await appApi.replyTicket(ticketId.value, replyContent.value.trim(), replyAttachments.value);
|
||||
replyContent.value = "";
|
||||
replyAttachments.value = [];
|
||||
showInfoToast("已发送");
|
||||
await fetchDetail();
|
||||
} catch (error) {
|
||||
showErrorToast(error, "发送失败");
|
||||
} finally {
|
||||
replying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function previewMessageAttachments(attachments: TicketAttachmentAsset[], current: string) {
|
||||
if (!attachments.length) return;
|
||||
uni.previewImage({
|
||||
urls: attachments.map((item) => item.file_url),
|
||||
current,
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseAttachments() {
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 3,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
if (!result.tempFilePaths?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await appApi.uploadTicketFile(filePath);
|
||||
replyAttachments.value.push(asset);
|
||||
}
|
||||
showInfoToast("附件上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "附件上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAttachment(fileUrl: string) {
|
||||
try {
|
||||
await appApi.deleteTicketFile(fileUrl);
|
||||
replyAttachments.value = replyAttachments.value.filter((item) => item.file_url !== fileUrl);
|
||||
showInfoToast("附件已删除");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "附件删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
ticketId.value = Number(options?.id || 0);
|
||||
if (!ticketId.value) {
|
||||
loadError.value = "缺少工单编号,无法查看详情。";
|
||||
}
|
||||
});
|
||||
|
||||
onShow(fetchDetail);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载工单详情</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步当前工单的状态和沟通记录。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">工单详情加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="fetchDetail">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="section-card section-card--soft">
|
||||
<view style="display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap">
|
||||
<text class="tag" :class="detail.ticket_info.status === 'resolved' ? 'tag--success' : detail.ticket_info.status === 'pending' ? 'tag--warning' : 'tag--info'">
|
||||
{{ detail.ticket_info.status_text }}
|
||||
</text>
|
||||
<text class="certificate-meta-chip">{{ detail.ticket_info.ticket_type_text }}</text>
|
||||
<text class="certificate-meta-chip">{{ detail.ticket_info.ticket_no }}</text>
|
||||
</view>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
{{ detail.ticket_info.title }}
|
||||
</view>
|
||||
<view class="section__desc">{{ detail.ticket_info.content }}</view>
|
||||
<view v-if="currentStatusDesc" class="form-group__hint" style="margin-top: 16rpx;">
|
||||
{{ currentStatusDesc }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.order_info" class="section section-card section-card--soft">
|
||||
<view class="section__title">关联订单</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">订单号</text>
|
||||
<text class="report-meta__value">{{ maskOrderNo(detail.order_info.order_no, privacyMode) }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">当前状态</text>
|
||||
<text class="report-meta__value">{{ detail.order_info.display_status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section__heading">
|
||||
<view>
|
||||
<view class="section__title">沟通记录</view>
|
||||
<view class="section__desc">您可以在这里补充问题,客服回复后也会展示在本页。</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="conversation-thread">
|
||||
<view
|
||||
v-for="item in detail.messages"
|
||||
:key="`${item.sender_type}-${item.created_at}`"
|
||||
:class="['conversation-bubble', item.sender_type === 'user' ? 'conversation-bubble--user' : item.sender_type === 'customer_service' ? 'conversation-bubble--service' : 'conversation-bubble--system']"
|
||||
>
|
||||
<view class="conversation-bubble__meta">
|
||||
<text>{{ item.sender_type_text }}</text>
|
||||
<text>{{ item.created_at }}</text>
|
||||
</view>
|
||||
<view class="conversation-bubble__content">{{ item.content }}</view>
|
||||
<view v-if="item.attachments.length" class="task-files" style="margin-top: 16rpx">
|
||||
<view v-for="attachment in item.attachments" :key="attachment.file_id" class="task-file">
|
||||
<image class="task-file__img" :src="attachment.thumbnail_url" mode="aspectFill" @click="previewMessageAttachments(item.attachments, attachment.file_url)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section form-panel">
|
||||
<view class="form-panel__title">继续补充说明</view>
|
||||
<view class="textarea-box" style="margin-top: 16rpx">
|
||||
<textarea
|
||||
v-model="replyContent"
|
||||
class="textarea-box__input"
|
||||
maxlength="500"
|
||||
placeholder="如果有新的截图说明、订单变化或补充信息,可以继续留言给客服。"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="replyAttachments.length" class="task-files" style="margin-top: 16rpx">
|
||||
<view v-for="item in replyAttachments" :key="item.file_id" class="task-file">
|
||||
<image class="task-file__img" :src="item.thumbnail_url" mode="aspectFill" @click="previewMessageAttachments(replyAttachments, item.file_url)" />
|
||||
<view class="task-file__remove" @click="removeAttachment(item.file_url)">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-card__row" style="margin-top: 20rpx">
|
||||
<text class="info-list__label">已上传 {{ replyAttachments.length }} 张</text>
|
||||
<text class="btn btn--ghost" @click="chooseAttachments">
|
||||
{{ uploading ? "上传中..." : "上传附件" }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="form-group__hint">发送后工单会继续进入处理中,客服会在后台工单中心跟进。</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view v-if="detail.order_info" class="btn btn--secondary" @click="goOrderDetail">
|
||||
查看订单
|
||||
</view>
|
||||
<view :class="['btn', 'btn--primary', replying ? 'btn--disabled' : '']" @click="submitReply">
|
||||
{{ replying ? "发送中..." : "发送留言" }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
152
user-app/src/pages/support/index.vue
Normal file
152
user-app/src/pages/support/index.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type TicketOverviewCard, type TicketTypeOption, type UserTicketListItem } from "../../api/app";
|
||||
import { resolveErrorMessage, showErrorToast } from "../../utils/feedback";
|
||||
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
|
||||
|
||||
const cards = ref<TicketOverviewCard[]>([]);
|
||||
const tickets = ref<UserTicketListItem[]>([]);
|
||||
const privacyMode = ref(getPrivacyMode());
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const quickTypes = ref<TicketTypeOption[]>([]);
|
||||
|
||||
function createTicket(ticketType = "") {
|
||||
const suffix = ticketType ? `?ticket_type=${ticketType}` : "";
|
||||
uni.navigateTo({ url: `/pages/support/create${suffix}` });
|
||||
}
|
||||
|
||||
function goMessages() {
|
||||
uni.navigateTo({ url: "/pages/message/index" });
|
||||
}
|
||||
|
||||
function openTicket(id: number) {
|
||||
uni.navigateTo({ url: `/pages/support/detail?id=${id}` });
|
||||
}
|
||||
|
||||
async function fetchTickets() {
|
||||
loading.value = true;
|
||||
privacyMode.value = getPrivacyMode();
|
||||
if (!pageReady.value) {
|
||||
loadError.value = "";
|
||||
}
|
||||
try {
|
||||
const [overview, list] = await Promise.all([
|
||||
appApi.getTicketOverview(),
|
||||
appApi.getTickets(),
|
||||
]);
|
||||
cards.value = overview.cards;
|
||||
quickTypes.value = overview.ticket_types.filter((item) => item.quick_desc).slice(0, 4);
|
||||
tickets.value = list.list;
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("ticket fallback", error);
|
||||
if (!pageReady.value) {
|
||||
loadError.value = resolveErrorMessage(error, "工单数据加载失败,请稍后重试。");
|
||||
} else {
|
||||
showErrorToast(error, "工单数据加载失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(fetchTickets);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在加载工单数据</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步工单统计与最近处理记录。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">工单数据加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
<view style="margin-top: 20rpx">
|
||||
<text class="btn btn--ghost" @click="fetchTickets">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="page-hero page-hero--layered">
|
||||
<view class="page-hero__eyebrow">客服支持</view>
|
||||
<view class="page-title">工单与服务支持中心</view>
|
||||
<view class="page-subtitle">提交问题、查看客服回复,并跟踪每一条工单的处理进度。</view>
|
||||
<view class="hero-actions">
|
||||
<view class="hero-actions__item hero-actions__item--gold" @click="createTicket()">发起工单</view>
|
||||
<view class="hero-actions__item hero-actions__item--light" @click="goMessages">查看消息</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section quick-grid">
|
||||
<view v-for="item in cards" :key="item.title" class="quick-card">
|
||||
<view class="quick-card__title">{{ item.title }}</view>
|
||||
<view class="support-stat__value">{{ item.value }}</view>
|
||||
<view class="quick-card__desc">{{ item.desc }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__heading">
|
||||
<view>
|
||||
<view class="section__title">常见问题入口</view>
|
||||
<view class="section__desc">按问题类型快速提交,客服会更快分流处理。</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="quick-grid">
|
||||
<view
|
||||
v-for="item in quickTypes"
|
||||
:key="item.code"
|
||||
class="quick-card"
|
||||
@click="createTicket(item.code)"
|
||||
>
|
||||
<view class="quick-card__title">{{ item.title }}</view>
|
||||
<view class="quick-card__desc">{{ item.quick_desc }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section__heading">
|
||||
<view>
|
||||
<view class="section__title">最近工单</view>
|
||||
<view class="section__desc">查看当前处理中的问题与最新客服反馈。</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in tickets"
|
||||
:key="item.id"
|
||||
class="section message-card"
|
||||
@click="openTicket(item.id)"
|
||||
>
|
||||
<view class="message-card__top">
|
||||
<view style="flex: 1">
|
||||
<view style="display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap">
|
||||
<text class="message-card__title">{{ item.title }}</text>
|
||||
<text class="tag" :class="item.status === 'resolved' ? 'tag--success' : item.status === 'pending' ? 'tag--warning' : 'tag--info'">
|
||||
{{ item.status_text }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="message-card__time">{{ item.updated_at }}</text>
|
||||
</view>
|
||||
<view class="message-card__content">{{ item.latest_message || "暂无沟通记录" }}</view>
|
||||
<view class="message-card__footer">
|
||||
<text class="message-card__state">{{ item.ticket_type_text }} · {{ maskOrderNo(item.ticket_no, privacyMode) }}</text>
|
||||
<text class="btn btn--ghost">查看详情</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!tickets.length" class="section section-card section-note">
|
||||
<view class="section__title">还没有工单</view>
|
||||
<view class="section__desc">如果您在下单、补资料或查看报告过程中遇到问题,可以直接发起工单。</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
213
user-app/src/pages/verify/result.vue
Normal file
213
user-app/src/pages/verify/result.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { appApi, type EvidenceAttachmentAsset, type VerifyData } from "../../api/app";
|
||||
import { verifyFallback } from "../../mocks/app";
|
||||
import { resolveErrorMessage } from "../../utils/feedback";
|
||||
|
||||
const detail = ref<VerifyData>(verifyFallback);
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const reportNo = computed(() => detail.value.report_summary.report_no || "");
|
||||
const isValid = computed(() => detail.value.verify_status === "valid");
|
||||
const statusTag = computed(() => (isValid.value ? "报告有效" : detail.value.verify_status === "invalid" ? "报告失效" : "报告状态更新"));
|
||||
const statusTitle = computed(() => (isValid.value ? "验真通过" : detail.value.verify_status === "invalid" ? "当前报告已失效" : "请以最新版本为准"));
|
||||
const imageEvidenceAttachments = computed(() =>
|
||||
detail.value.evidence_attachments.filter((item) => item.file_type === "image"),
|
||||
);
|
||||
const otherEvidenceAttachments = computed(() =>
|
||||
detail.value.evidence_attachments.filter((item) => item.file_type !== "image"),
|
||||
);
|
||||
|
||||
function goReport() {
|
||||
if (!reportNo.value) {
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/report/detail?report_no=${encodeURIComponent(reportNo.value)}` });
|
||||
}
|
||||
|
||||
function previewEvidenceImage(current: string) {
|
||||
if (!imageEvidenceAttachments.value.length) return;
|
||||
uni.previewImage({
|
||||
urls: imageEvidenceAttachments.value.map((item) => item.file_url),
|
||||
current,
|
||||
});
|
||||
}
|
||||
|
||||
function evidenceTypeText(fileType: string) {
|
||||
if (fileType === "video") return "视频";
|
||||
if (fileType === "pdf") return "PDF";
|
||||
if (fileType === "image") return "图片";
|
||||
return "附件";
|
||||
}
|
||||
|
||||
function evidenceDisplayName(item: EvidenceAttachmentAsset, index: number) {
|
||||
return item.name || `${evidenceTypeText(item.file_type)} ${index + 1}`;
|
||||
}
|
||||
|
||||
function openEvidenceAttachment(item: EvidenceAttachmentAsset) {
|
||||
if (item.file_type === "image") {
|
||||
previewEvidenceImage(item.file_url);
|
||||
return;
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
window.open(item.file_url, "_blank");
|
||||
return;
|
||||
// #endif
|
||||
|
||||
if (item.file_type === "pdf") {
|
||||
uni.showLoading({ title: "准备打开" });
|
||||
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" }),
|
||||
});
|
||||
},
|
||||
fail: () => uni.showToast({ title: "附件打开失败", icon: "none" }),
|
||||
complete: () => uni.hideLoading(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const previewMedia = (uni as any).previewMedia;
|
||||
if (typeof previewMedia === "function" && item.file_type === "video") {
|
||||
previewMedia({
|
||||
sources: [{ url: item.file_url, type: "video", poster: item.thumbnail_url || "" }],
|
||||
current: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({ title: "当前附件类型暂不支持预览", icon: "none" });
|
||||
}
|
||||
|
||||
function contactService() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/support/create?ticket_type=report_issue&prefill_title=${encodeURIComponent("验真结果咨询")}`,
|
||||
});
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
const currentReportNo = String(options?.report_no || "");
|
||||
if (!currentReportNo) {
|
||||
loadError.value = "缺少报告编号,无法完成验真。";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await appApi.verifyReport(currentReportNo);
|
||||
pageReady.value = true;
|
||||
} catch (error) {
|
||||
console.warn("verify fallback", error);
|
||||
loadError.value = resolveErrorMessage(error, "验真结果加载失败,请稍后重试。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page app-page--tight">
|
||||
<view v-if="!pageReady && loading" class="section notice-card">
|
||||
<view class="notice-card__title">正在验证报告</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在核验报告编号与最新验真状态。</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pageReady && loadError" class="section notice-card">
|
||||
<view class="notice-card__title">验真结果加载失败</view>
|
||||
<view class="notice-card__desc">{{ loadError }}</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="status-card section-card section-card--soft">
|
||||
<text class="tag" :class="isValid ? 'tag--success' : 'tag--warning'">{{ statusTag }}</text>
|
||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||
{{ statusTitle }}
|
||||
</view>
|
||||
<view class="section__desc">{{ detail.verify_message }}</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card section-card--soft">
|
||||
<view class="section__title">验真说明</view>
|
||||
<view class="section__desc">验真页的定位不是再解释报告内容,而是帮助用户快速确认“这份报告是否真实有效”。如果仍有疑问,可以继续联系人工协助。</view>
|
||||
<view class="chip-list" style="margin-top: 18rpx;">
|
||||
<text :class="['choice-chip', 'choice-chip--selected']">{{ statusTag }}</text>
|
||||
<text class="choice-chip">编号可追溯</text>
|
||||
<text class="choice-chip">结果以本页为准</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">报告摘要</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">报告编号</text>
|
||||
<text class="report-meta__value">{{ detail.report_summary.report_no }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">鉴定机构</text>
|
||||
<text class="report-meta__value">{{ detail.report_summary.institution_name }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">鉴定结论</text>
|
||||
<text class="report-meta__value">{{ detail.result_summary.result_text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section-card">
|
||||
<view class="section__title">商品摘要</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">商品名称</text>
|
||||
<text class="report-meta__value">{{ detail.product_summary.product_name }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">品类 / 品牌</text>
|
||||
<text class="report-meta__value">{{ detail.product_summary.category_name }} / {{ detail.product_summary.brand_name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.evidence_attachments.length" class="section section-card">
|
||||
<view class="section__title">鉴定证据附件</view>
|
||||
<view class="section__desc">验真页同步展示该报告留存的证据附件,便于快速确认报告依据。</view>
|
||||
|
||||
<view v-if="imageEvidenceAttachments.length" class="task-files" style="margin-top: 20rpx;">
|
||||
<view
|
||||
v-for="item in imageEvidenceAttachments"
|
||||
:key="item.file_id"
|
||||
class="task-file"
|
||||
@click="previewEvidenceImage(item.file_url)"
|
||||
>
|
||||
<image class="task-file__img" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="otherEvidenceAttachments.length" style="margin-top: 20rpx;">
|
||||
<view
|
||||
v-for="(item, index) in otherEvidenceAttachments"
|
||||
:key="item.file_id"
|
||||
class="info-list__row"
|
||||
@click="openEvidenceAttachment(item)"
|
||||
>
|
||||
<text class="info-list__label">{{ evidenceDisplayName(item, index) }}</text>
|
||||
<text class="info-list__value">{{ evidenceTypeText(item.file_type) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="btn btn--secondary" @click="contactService">联系客服</view>
|
||||
<view class="btn btn--primary" @click="goReport">查看报告</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
6
user-app/src/shime-uni.d.ts
vendored
Normal file
6
user-app/src/shime-uni.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
export {}
|
||||
|
||||
declare module "vue" {
|
||||
type Hooks = App.AppInstance & Page.PageInstance;
|
||||
interface ComponentCustomOptions extends Hooks {}
|
||||
}
|
||||
BIN
user-app/src/static/home/home-reference.jpg
Normal file
BIN
user-app/src/static/home/home-reference.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 601 KiB |
BIN
user-app/src/static/logo.png
Normal file
BIN
user-app/src/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
user-app/src/static/order/order-reference.jpg
Normal file
BIN
user-app/src/static/order/order-reference.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
BIN
user-app/src/static/report/report-reference.jpg
Normal file
BIN
user-app/src/static/report/report-reference.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
271
user-app/src/stores/appraisal.ts
Normal file
271
user-app/src/stores/appraisal.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { PreviewData, SubmitResult, UploadFileAsset, UploadItem } from "../api/appraisal";
|
||||
|
||||
type ProductState = {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
brandId: number;
|
||||
brandName: string;
|
||||
};
|
||||
|
||||
type ExtraState = {
|
||||
purchaseChannel: string;
|
||||
purchasePrice: string;
|
||||
purchaseDate: string;
|
||||
usageStatus: string;
|
||||
conditionDesc: string;
|
||||
accessories: string[];
|
||||
remark: string;
|
||||
};
|
||||
|
||||
type ReturnAddressState = {
|
||||
id: number;
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detailAddress: string;
|
||||
fullAddress: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
const storageKey = "anxinyan_appraisal_flow";
|
||||
const legacyDemoExtra: ExtraState = {
|
||||
purchaseChannel: "专柜",
|
||||
purchasePrice: "8500",
|
||||
purchaseDate: "",
|
||||
usageStatus: "light_use",
|
||||
conditionDesc: "轻微使用痕迹",
|
||||
accessories: ["防尘袋", "包装盒"],
|
||||
remark: "",
|
||||
};
|
||||
|
||||
function initialProduct(): ProductState {
|
||||
return {
|
||||
categoryId: 0,
|
||||
categoryName: "",
|
||||
brandId: 0,
|
||||
brandName: "",
|
||||
};
|
||||
}
|
||||
|
||||
function initialExtra(): ExtraState {
|
||||
return {
|
||||
purchaseChannel: "",
|
||||
purchasePrice: "",
|
||||
purchaseDate: "",
|
||||
usageStatus: "",
|
||||
conditionDesc: "",
|
||||
accessories: [],
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
function isLegacyDemoExtra(extra: Partial<ExtraState> | undefined) {
|
||||
if (!extra) return false;
|
||||
const accessories = Array.isArray(extra.accessories) ? extra.accessories : [];
|
||||
return (
|
||||
extra.purchaseChannel === legacyDemoExtra.purchaseChannel &&
|
||||
extra.purchasePrice === legacyDemoExtra.purchasePrice &&
|
||||
(extra.purchaseDate || "") === legacyDemoExtra.purchaseDate &&
|
||||
extra.usageStatus === legacyDemoExtra.usageStatus &&
|
||||
extra.conditionDesc === legacyDemoExtra.conditionDesc &&
|
||||
(extra.remark || "") === legacyDemoExtra.remark &&
|
||||
accessories.length === legacyDemoExtra.accessories.length &&
|
||||
accessories.every((item, index) => item === legacyDemoExtra.accessories[index])
|
||||
);
|
||||
}
|
||||
|
||||
function initialReturnAddress(): ReturnAddressState {
|
||||
return {
|
||||
id: 0,
|
||||
consignee: "",
|
||||
mobile: "",
|
||||
province: "",
|
||||
city: "",
|
||||
district: "",
|
||||
detailAddress: "",
|
||||
fullAddress: "",
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const useAppraisalStore = defineStore("appraisal", {
|
||||
state: () => ({
|
||||
draftId: 0,
|
||||
serviceProvider: "anxinyan",
|
||||
serviceMode: "physical",
|
||||
currentStep: 1,
|
||||
product: initialProduct(),
|
||||
extra: initialExtra(),
|
||||
returnAddress: initialReturnAddress(),
|
||||
uploadTemplateId: 0,
|
||||
requiredItems: [] as UploadItem[],
|
||||
optionalItems: [] as UploadItem[],
|
||||
preview: null as PreviewData | null,
|
||||
submitResult: null as SubmitResult | null,
|
||||
}),
|
||||
actions: {
|
||||
hydrate() {
|
||||
const raw = uni.getStorageSync(storageKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
Object.assign(this, parsed);
|
||||
if (isLegacyDemoExtra(this.extra)) {
|
||||
this.extra = initialExtra();
|
||||
this.persist();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("hydrate appraisal store failed", error);
|
||||
}
|
||||
},
|
||||
persist() {
|
||||
uni.setStorageSync(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
draftId: this.draftId,
|
||||
serviceProvider: this.serviceProvider,
|
||||
serviceMode: this.serviceMode,
|
||||
currentStep: this.currentStep,
|
||||
product: this.product,
|
||||
extra: this.extra,
|
||||
returnAddress: this.returnAddress,
|
||||
uploadTemplateId: this.uploadTemplateId,
|
||||
requiredItems: this.requiredItems,
|
||||
optionalItems: this.optionalItems,
|
||||
preview: this.preview,
|
||||
submitResult: this.submitResult,
|
||||
}),
|
||||
);
|
||||
},
|
||||
setServiceProvider(serviceProvider: string) {
|
||||
this.serviceProvider = serviceProvider;
|
||||
this.persist();
|
||||
},
|
||||
setDraft(id: number) {
|
||||
this.draftId = id;
|
||||
this.persist();
|
||||
},
|
||||
setCurrentStep(step: number) {
|
||||
this.currentStep = step;
|
||||
this.persist();
|
||||
},
|
||||
setProduct(payload: Partial<ProductState>) {
|
||||
this.product = {
|
||||
...this.product,
|
||||
...payload,
|
||||
};
|
||||
this.persist();
|
||||
},
|
||||
setExtra(payload: Partial<ExtraState>) {
|
||||
this.extra = {
|
||||
...this.extra,
|
||||
...payload,
|
||||
};
|
||||
this.persist();
|
||||
},
|
||||
resetExtra() {
|
||||
this.extra = initialExtra();
|
||||
this.persist();
|
||||
},
|
||||
clearLegacyExtraDefaults() {
|
||||
if (!isLegacyDemoExtra(this.extra)) {
|
||||
return false;
|
||||
}
|
||||
this.extra = initialExtra();
|
||||
this.persist();
|
||||
return true;
|
||||
},
|
||||
setReturnAddress(payload: Partial<ReturnAddressState>) {
|
||||
this.returnAddress = {
|
||||
...this.returnAddress,
|
||||
...payload,
|
||||
};
|
||||
this.persist();
|
||||
},
|
||||
clearReturnAddress() {
|
||||
this.returnAddress = initialReturnAddress();
|
||||
this.persist();
|
||||
},
|
||||
setUploadTemplate(templateId: number, requiredItems: UploadItem[], optionalItems: UploadItem[]) {
|
||||
const requiredMap = new Map(this.requiredItems.map((item) => [item.item_code, item.files || []]));
|
||||
const optionalMap = new Map(this.optionalItems.map((item) => [item.item_code, item.files || []]));
|
||||
this.uploadTemplateId = templateId;
|
||||
this.requiredItems = requiredItems.map((item) => ({
|
||||
...item,
|
||||
files: item.files || requiredMap.get(item.item_code) || [],
|
||||
}));
|
||||
this.optionalItems = optionalItems.map((item) => ({
|
||||
...item,
|
||||
files: item.files || optionalMap.get(item.item_code) || [],
|
||||
}));
|
||||
this.persist();
|
||||
},
|
||||
hydrateUploadItems(items: UploadItem[]) {
|
||||
const required = items.filter((item) => item.is_required);
|
||||
const optional = items.filter((item) => !item.is_required);
|
||||
this.requiredItems = required.map((item) => ({
|
||||
...item,
|
||||
files: item.files || [],
|
||||
}));
|
||||
this.optionalItems = optional.map((item) => ({
|
||||
...item,
|
||||
files: item.files || [],
|
||||
}));
|
||||
this.persist();
|
||||
},
|
||||
upsertUploadFile(itemCode: string, file: UploadFileAsset, isRequired: boolean) {
|
||||
const target = isRequired ? this.requiredItems : this.optionalItems;
|
||||
const index = target.findIndex((item) => item.item_code === itemCode);
|
||||
if (index >= 0) {
|
||||
const currentFiles = target[index].files || [];
|
||||
const nextFiles = [...currentFiles, file];
|
||||
target[index] = {
|
||||
...target[index],
|
||||
files: nextFiles,
|
||||
quality_status: nextFiles.length ? "uploaded" : "pending",
|
||||
quality_message: "",
|
||||
};
|
||||
this.persist();
|
||||
}
|
||||
},
|
||||
removeUploadFile(itemCode: string, fileId: string, isRequired: boolean) {
|
||||
const target = isRequired ? this.requiredItems : this.optionalItems;
|
||||
const index = target.findIndex((item) => item.item_code === itemCode);
|
||||
if (index >= 0) {
|
||||
const nextFiles = (target[index].files || []).filter((item) => item.file_id !== fileId);
|
||||
target[index] = {
|
||||
...target[index],
|
||||
files: nextFiles,
|
||||
quality_status: nextFiles.length ? "uploaded" : (target[index].is_required ? "pending" : "optional"),
|
||||
};
|
||||
this.persist();
|
||||
}
|
||||
},
|
||||
setPreview(preview: PreviewData | null) {
|
||||
this.preview = preview;
|
||||
this.persist();
|
||||
},
|
||||
setSubmitResult(result: SubmitResult | null) {
|
||||
this.submitResult = result;
|
||||
this.persist();
|
||||
},
|
||||
resetForNewFlow() {
|
||||
this.serviceProvider = "anxinyan";
|
||||
this.serviceMode = "physical";
|
||||
this.draftId = 0;
|
||||
this.currentStep = 1;
|
||||
this.product = initialProduct();
|
||||
this.extra = initialExtra();
|
||||
this.returnAddress = initialReturnAddress();
|
||||
this.uploadTemplateId = 0;
|
||||
this.requiredItems = [];
|
||||
this.optionalItems = [];
|
||||
this.preview = null;
|
||||
this.submitResult = null;
|
||||
this.persist();
|
||||
},
|
||||
},
|
||||
});
|
||||
1339
user-app/src/styles/app.scss
Normal file
1339
user-app/src/styles/app.scss
Normal file
File diff suppressed because it is too large
Load Diff
104
user-app/src/styles/tokens.scss
Normal file
104
user-app/src/styles/tokens.scss
Normal file
@@ -0,0 +1,104 @@
|
||||
:root,
|
||||
page {
|
||||
--color-brand-black: #252527;
|
||||
--color-brand-gold: #edbd00;
|
||||
--color-brand-gold-deep: #c89b00;
|
||||
--color-brand-ivory: #f7f7f8;
|
||||
--color-brand-cream: #f2f2f4;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-text-primary: #252527;
|
||||
--color-text-secondary: #6a6a6d;
|
||||
--color-text-tertiary: #9a9a9d;
|
||||
--color-border: #ebebee;
|
||||
--color-bg-soft: #f6f6f7;
|
||||
--color-bg-page: #f2f2f4;
|
||||
|
||||
--color-success: #2f6b4f;
|
||||
--color-success-bg: #eaf4ee;
|
||||
--color-warning: #a97700;
|
||||
--color-warning-bg: #fff4d9;
|
||||
--color-danger: #9f3b32;
|
||||
--color-danger-bg: #fbedea;
|
||||
--color-info: #355c7d;
|
||||
--color-info-bg: #eef4f8;
|
||||
|
||||
--font-family-base: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-family-display: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
|
||||
--font-size-xs: 24rpx;
|
||||
--font-size-sm: 28rpx;
|
||||
--font-size-md: 32rpx;
|
||||
--font-size-lg: 36rpx;
|
||||
--font-size-xl: 40rpx;
|
||||
--font-size-2xl: 48rpx;
|
||||
--font-size-hero: 52rpx;
|
||||
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
--line-height-tight: 1.2;
|
||||
--line-height-base: 1.5;
|
||||
--line-height-relaxed: 1.7;
|
||||
|
||||
--radius-sm: 8rpx;
|
||||
--radius-md: 12rpx;
|
||||
--radius-lg: 16rpx;
|
||||
--radius-xl: 16rpx;
|
||||
--radius-pill: 999rpx;
|
||||
|
||||
--shadow-sm: 0 10rpx 24rpx rgba(0, 0, 0, 0.035);
|
||||
--shadow-md: 0 16rpx 34rpx rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 24rpx 54rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
--page-padding-x: 32rpx;
|
||||
--page-section-gap: 48rpx;
|
||||
--page-block-gap: 64rpx;
|
||||
|
||||
--button-height-md: 96rpx;
|
||||
--button-height-lg: 104rpx;
|
||||
|
||||
--input-height: 96rpx;
|
||||
--input-bg: rgba(255, 255, 255, 0.88);
|
||||
--input-border: #ededf0;
|
||||
--input-border-focus: #edbd00;
|
||||
--input-placeholder: #a8a8ab;
|
||||
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #eeeeef;
|
||||
--card-padding: 32rpx;
|
||||
--card-padding-lg: 40rpx;
|
||||
|
||||
--tag-radius: 999rpx;
|
||||
|
||||
--color-primary: var(--color-brand-black);
|
||||
--color-accent: var(--color-brand-gold);
|
||||
--color-page-bg: var(--color-bg-page);
|
||||
--color-card-bg: var(--card-bg);
|
||||
--color-heading: var(--color-text-primary);
|
||||
--color-body: var(--color-text-secondary);
|
||||
--color-muted: var(--color-text-tertiary);
|
||||
|
||||
--color-status-success: var(--color-success);
|
||||
--color-status-success-bg: var(--color-success-bg);
|
||||
--color-status-warning: var(--color-warning);
|
||||
--color-status-warning-bg: var(--color-warning-bg);
|
||||
--color-status-danger: var(--color-danger);
|
||||
--color-status-danger-bg: var(--color-danger-bg);
|
||||
--color-status-info: var(--color-info);
|
||||
--color-status-info-bg: var(--color-info-bg);
|
||||
|
||||
--button-primary-bg: #edbd00;
|
||||
--button-primary-text: #ffffff;
|
||||
--button-primary-shadow: var(--shadow-sm);
|
||||
--button-secondary-bg: #ffffff;
|
||||
--button-secondary-text: var(--color-brand-black);
|
||||
--button-secondary-border: #eeeeef;
|
||||
|
||||
--fixed-action-bar-bg: rgba(255, 255, 255, 0.94);
|
||||
--fixed-action-bar-border: #e9e9eb;
|
||||
|
||||
--z-fixed-bar: 100;
|
||||
}
|
||||
76
user-app/src/uni.scss
Normal file
76
user-app/src/uni.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 这里是uni-app内置的常用样式变量
|
||||
*
|
||||
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
|
||||
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||
*
|
||||
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
|
||||
*/
|
||||
|
||||
/* 颜色变量 */
|
||||
|
||||
/* 行为相关颜色 */
|
||||
$uni-color-primary: #007aff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 文字基本颜色 */
|
||||
$uni-text-color: #333; // 基本色
|
||||
$uni-text-color-inverse: #fff; // 反色
|
||||
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
|
||||
$uni-text-color-placeholder: #808080;
|
||||
$uni-text-color-disable: #c0c0c0;
|
||||
|
||||
/* 背景颜色 */
|
||||
$uni-bg-color: #fff;
|
||||
$uni-bg-color-grey: #f8f8f8;
|
||||
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
|
||||
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
|
||||
|
||||
/* 边框颜色 */
|
||||
$uni-border-color: #c8c7cc;
|
||||
|
||||
/* 尺寸变量 */
|
||||
|
||||
/* 文字尺寸 */
|
||||
$uni-font-size-sm: 12px;
|
||||
$uni-font-size-base: 14px;
|
||||
$uni-font-size-lg: 16;
|
||||
|
||||
/* 图片尺寸 */
|
||||
$uni-img-size-sm: 20px;
|
||||
$uni-img-size-base: 26px;
|
||||
$uni-img-size-lg: 40px;
|
||||
|
||||
/* Border Radius */
|
||||
$uni-border-radius-sm: 2px;
|
||||
$uni-border-radius-base: 3px;
|
||||
$uni-border-radius-lg: 6px;
|
||||
$uni-border-radius-circle: 50%;
|
||||
|
||||
/* 水平间距 */
|
||||
$uni-spacing-row-sm: 5px;
|
||||
$uni-spacing-row-base: 10px;
|
||||
$uni-spacing-row-lg: 15px;
|
||||
|
||||
/* 垂直间距 */
|
||||
$uni-spacing-col-sm: 4px;
|
||||
$uni-spacing-col-base: 8px;
|
||||
$uni-spacing-col-lg: 12px;
|
||||
|
||||
/* 透明度 */
|
||||
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
|
||||
|
||||
/* 文章场景相关 */
|
||||
$uni-color-title: #2c405a; // 文章标题颜色
|
||||
$uni-font-size-title: 20px;
|
||||
$uni-color-subtitle: #555; // 二级标题颜色
|
||||
$uni-font-size-subtitle: 18px;
|
||||
$uni-color-paragraph: #3f536e; // 文章段落颜色
|
||||
$uni-font-size-paragraph: 15px;
|
||||
83
user-app/src/utils/appraisal-flow.ts
Normal file
83
user-app/src/utils/appraisal-flow.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { appraisalApi } from "../api/appraisal";
|
||||
import type { useAppraisalStore } from "../stores/appraisal";
|
||||
|
||||
type AppraisalStore = ReturnType<typeof useAppraisalStore>;
|
||||
|
||||
export function isMissingDraftError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
return message.includes("草稿不存在");
|
||||
}
|
||||
|
||||
export async function rebuildDraftFromStore(store: AppraisalStore) {
|
||||
const draft = await appraisalApi.createDraft(store.serviceProvider || "anxinyan");
|
||||
store.setDraft(draft.draft_id);
|
||||
|
||||
if (store.product.categoryId && store.product.brandId) {
|
||||
await appraisalApi.saveDraft({
|
||||
draft_id: draft.draft_id,
|
||||
current_step: 2,
|
||||
service_provider: store.serviceProvider,
|
||||
product_info: {
|
||||
category_id: store.product.categoryId,
|
||||
brand_id: store.product.brandId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const hasExtraInfo =
|
||||
Boolean(store.extra.purchaseChannel) ||
|
||||
Boolean(store.extra.purchasePrice) ||
|
||||
Boolean(store.extra.purchaseDate) ||
|
||||
Boolean(store.extra.usageStatus) ||
|
||||
Boolean(store.extra.conditionDesc) ||
|
||||
store.extra.accessories.length > 0 ||
|
||||
Boolean(store.extra.remark);
|
||||
|
||||
if (hasExtraInfo) {
|
||||
await appraisalApi.saveDraft({
|
||||
draft_id: draft.draft_id,
|
||||
current_step: 3,
|
||||
extra_info: {
|
||||
purchase_channel: store.extra.purchaseChannel,
|
||||
purchase_price: Number(store.extra.purchasePrice || 0),
|
||||
purchase_date: store.extra.purchaseDate || undefined,
|
||||
usage_status: store.extra.usageStatus,
|
||||
condition_desc: store.extra.conditionDesc,
|
||||
accessories: store.extra.accessories,
|
||||
remark: store.extra.remark,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const allUploadItems = [
|
||||
...store.requiredItems.map((item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
is_required: true,
|
||||
quality_status: item.files?.length ? "uploaded" : "pending",
|
||||
quality_message: "",
|
||||
files: item.files || [],
|
||||
})),
|
||||
...store.optionalItems.map((item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
is_required: false,
|
||||
quality_status: item.files?.length ? "uploaded" : "optional",
|
||||
quality_message: "",
|
||||
files: item.files || [],
|
||||
})),
|
||||
];
|
||||
|
||||
if (store.uploadTemplateId && allUploadItems.length) {
|
||||
await appraisalApi.saveDraft({
|
||||
draft_id: draft.draft_id,
|
||||
current_step: 4,
|
||||
upload_info: {
|
||||
template_id: store.uploadTemplateId,
|
||||
items: allUploadItems,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return draft.draft_id;
|
||||
}
|
||||
148
user-app/src/utils/auth.ts
Normal file
148
user-app/src/utils/auth.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
const TOKEN_KEY = "anxinyan_user_token";
|
||||
const LOGIN_REDIRECT_KEY = "anxinyan_user_login_redirect";
|
||||
|
||||
const TABBAR_PAGES = new Set([
|
||||
"/pages/home/index",
|
||||
"/pages/order/index",
|
||||
"/pages/report/index",
|
||||
"/pages/mine/index",
|
||||
]);
|
||||
|
||||
const PUBLIC_PAGES = new Set([
|
||||
"/pages/home/index",
|
||||
"/pages/help/index",
|
||||
"/pages/help/detail",
|
||||
"/pages/report/detail",
|
||||
"/pages/verify/result",
|
||||
"/pages/material-tag/detail",
|
||||
"/pages/auth/login",
|
||||
]);
|
||||
|
||||
let redirecting = false;
|
||||
|
||||
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("&");
|
||||
}
|
||||
|
||||
export function getUserToken() {
|
||||
return String(uni.getStorageSync(TOKEN_KEY) || "");
|
||||
}
|
||||
|
||||
export function setUserToken(token: string) {
|
||||
uni.setStorageSync(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearUserToken() {
|
||||
uni.removeStorageSync(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return getUserToken() !== "";
|
||||
}
|
||||
|
||||
export function buildAuthHeaders(headers: Record<string, string> = {}) {
|
||||
const token = getUserToken();
|
||||
if (!token) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
return {
|
||||
...headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWechatBrowser() {
|
||||
// #ifdef H5
|
||||
return /MicroMessenger/i.test(window.navigator.userAgent);
|
||||
// #endif
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPublicPage(urlOrPath: string) {
|
||||
const { path } = splitUrl(urlOrPath);
|
||||
return PUBLIC_PAGES.has(path);
|
||||
}
|
||||
|
||||
export 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 redirectToLogin(targetUrl?: string) {
|
||||
const currentUrl = targetUrl || getCurrentPageUrl();
|
||||
if (redirecting || isPublicPage(currentUrl || "/pages/auth/login")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUrl) {
|
||||
uni.setStorageSync(LOGIN_REDIRECT_KEY, currentUrl);
|
||||
}
|
||||
|
||||
redirecting = true;
|
||||
uni.navigateTo({
|
||||
url: `/pages/auth/login${currentUrl ? `?redirect=${encodeURIComponent(currentUrl)}` : ""}`,
|
||||
complete: () => {
|
||||
setTimeout(() => {
|
||||
redirecting = false;
|
||||
}, 200);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureAuthenticatedPageAccess() {
|
||||
const currentUrl = getCurrentPageUrl();
|
||||
if (!currentUrl || isPublicPage(currentUrl) || isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
redirectToLogin(currentUrl);
|
||||
}
|
||||
|
||||
export function consumeLoginRedirect(defaultUrl = "/pages/mine/index") {
|
||||
const stored = String(uni.getStorageSync(LOGIN_REDIRECT_KEY) || "");
|
||||
uni.removeStorageSync(LOGIN_REDIRECT_KEY);
|
||||
return stored || defaultUrl;
|
||||
}
|
||||
|
||||
export function navigateAfterLogin(defaultUrl = "/pages/mine/index") {
|
||||
const targetUrl = consumeLoginRedirect(defaultUrl);
|
||||
const { path } = splitUrl(targetUrl);
|
||||
|
||||
if (TABBAR_PAGES.has(path)) {
|
||||
uni.switchTab({ url: path });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.reLaunch({ url: targetUrl });
|
||||
}
|
||||
|
||||
export function logoutAndRedirect() {
|
||||
clearUserToken();
|
||||
uni.reLaunch({ url: "/pages/home/index" });
|
||||
}
|
||||
24
user-app/src/utils/env.ts
Normal file
24
user-app/src/utils/env.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const LOCAL_API_BASE_URL = "http://127.0.0.1:8787";
|
||||
|
||||
function isLocalLikeHostname(hostname: string) {
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
/^10\./.test(hostname) ||
|
||||
/^192\.168\./.test(hostname) ||
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveApiBaseUrl() {
|
||||
if (import.meta.env.DEV) {
|
||||
return LOCAL_API_BASE_URL;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && isLocalLikeHostname(window.location.hostname)) {
|
||||
return LOCAL_API_BASE_URL;
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_API_BASE_URL || LOCAL_API_BASE_URL;
|
||||
}
|
||||
42
user-app/src/utils/feedback.ts
Normal file
42
user-app/src/utils/feedback.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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: 1800,
|
||||
});
|
||||
}
|
||||
13
user-app/src/utils/order-source.ts
Normal file
13
user-app/src/utils/order-source.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type OrderSourceChannel = "mini_program" | "h5" | "enterprise_push";
|
||||
|
||||
export function resolveOrderSourceChannel(): OrderSourceChannel {
|
||||
// #ifdef H5
|
||||
return "h5";
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
return "mini_program";
|
||||
// #endif
|
||||
|
||||
return "mini_program";
|
||||
}
|
||||
38
user-app/src/utils/privacy.ts
Normal file
38
user-app/src/utils/privacy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const PRIVACY_MODE_KEY = "anxinyan_privacy_mode";
|
||||
|
||||
export function setPrivacyMode(enabled: boolean) {
|
||||
try {
|
||||
uni.setStorageSync(PRIVACY_MODE_KEY, enabled ? "1" : "0");
|
||||
} catch (error) {
|
||||
console.warn("set privacy mode failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getPrivacyMode() {
|
||||
try {
|
||||
return uni.getStorageSync(PRIVACY_MODE_KEY) === "1";
|
||||
} catch (error) {
|
||||
console.warn("get privacy mode failed", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function maskMiddle(value: string, left = 3, right = 3, mask = "****") {
|
||||
if (!value) return "";
|
||||
if (value.length <= left + right) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, left)}${mask}${value.slice(-right)}`;
|
||||
}
|
||||
|
||||
export function maskOrderNo(value: string, enabled: boolean) {
|
||||
return enabled ? maskMiddle(value, 4, 4, "******") : value;
|
||||
}
|
||||
|
||||
export function maskMobile(value: string, enabled: boolean) {
|
||||
return enabled ? maskMiddle(value, 3, 4, "****") : value;
|
||||
}
|
||||
|
||||
export function maskAddress(value: string, enabled: boolean) {
|
||||
return enabled ? maskMiddle(value, 6, 6, "******") : value;
|
||||
}
|
||||
75
user-app/src/utils/qrcode.ts
Normal file
75
user-app/src/utils/qrcode.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// @ts-ignore
|
||||
import QRCodeGenerator from "qrcode-terminal/vendor/QRCode";
|
||||
// @ts-ignore
|
||||
import QRErrorCorrectLevel from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel";
|
||||
|
||||
function isImageUrl(value: string) {
|
||||
return /^(data:image\/|https?:.*\.(png|jpg|jpeg|gif|webp|svg)(\?.*)?$|\/.*\.(png|jpg|jpeg|gif|webp|svg)(\?.*)?$)/i.test(value);
|
||||
}
|
||||
|
||||
export function resolveQrImageSource(qrcodeUrl: string, verifyUrl: string) {
|
||||
const qrcodeValue = (qrcodeUrl || "").trim();
|
||||
const verifyValue = normalizeQrPayload((verifyUrl || "").trim());
|
||||
|
||||
if (qrcodeValue && isImageUrl(qrcodeValue)) {
|
||||
return qrcodeValue;
|
||||
}
|
||||
|
||||
const payload = verifyValue || normalizeQrPayload(qrcodeValue);
|
||||
if (!payload) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return createQrSvgDataUrl(payload);
|
||||
}
|
||||
|
||||
function normalizeQrPayload(value: string) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(value) || value.startsWith("data:")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value.startsWith("/")) {
|
||||
// #ifdef H5
|
||||
return `${window.location.origin}${value}`;
|
||||
// #endif
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createQrSvgDataUrl(text: string, cellSize = 6, margin = 2) {
|
||||
const value = text.trim();
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const qr = new QRCodeGenerator(-1, QRErrorCorrectLevel.M);
|
||||
qr.addData(value);
|
||||
qr.make();
|
||||
|
||||
const moduleCount = qr.getModuleCount();
|
||||
const viewBoxSize = (moduleCount + margin * 2) * cellSize;
|
||||
const commands: string[] = [];
|
||||
|
||||
for (let row = 0; row < moduleCount; row += 1) {
|
||||
for (let col = 0; col < moduleCount; col += 1) {
|
||||
if (!qr.isDark(row, col)) continue;
|
||||
const x = (col + margin) * cellSize;
|
||||
const y = (row + margin) * cellSize;
|
||||
commands.push(`M${x} ${y}h${cellSize}v${cellSize}H${x}z`);
|
||||
}
|
||||
}
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${viewBoxSize} ${viewBoxSize}" shape-rendering="crispEdges">
|
||||
<rect width="${viewBoxSize}" height="${viewBoxSize}" fill="#ffffff" />
|
||||
<path d="${commands.join(" ")}" fill="#1a1a1a" />
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
93
user-app/src/utils/request.ts
Normal file
93
user-app/src/utils/request.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { buildAuthHeaders, clearUserToken, redirectToLogin } from "./auth";
|
||||
import { resolveApiBaseUrl } from "./env";
|
||||
|
||||
const BASE_URL = resolveApiBaseUrl();
|
||||
|
||||
type RequestMethod = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "HEAD";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
function buildUrl(url: string, params?: Record<string, string | number | undefined>) {
|
||||
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>;
|
||||
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" && options.data) {
|
||||
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) {
|
||||
clearUserToken();
|
||||
if (!url.startsWith("/api/app/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) {
|
||||
clearUserToken();
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
throw new Error(payload?.message || fallback);
|
||||
}
|
||||
13
user-app/tsconfig.json
Normal file
13
user-app/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"lib": ["esnext", "dom"],
|
||||
"types": ["@dcloudio/types"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
17
user-app/vite.config.ts
Normal file
17
user-app/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import uni from "@dcloudio/vite-plugin-uni";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user