This commit is contained in:
wushumin
2026-05-11 15:28:27 +08:00
commit 9aac78b8da
289 changed files with 67193 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

74
user-app/package.json Normal file
View 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
View 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
View 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
View 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,
},
});
},
};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,6 @@
export {}
declare module "vue" {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

View 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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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