feat: update appraisal return address and test packaging assets

This commit is contained in:
wushumin
2026-06-15 20:08:36 +08:00
parent fa267c4413
commit 9be60fbe17
23 changed files with 1806 additions and 393 deletions

View File

@@ -174,6 +174,7 @@ export interface AdminOrderListItem {
id: number;
order_no: string;
appraisal_no: string;
external_order_no?: string;
product_name: string;
category_name: string;
brand_name: string;
@@ -197,6 +198,7 @@ export interface AdminOrderDetail {
id: number;
order_no: string;
appraisal_no: string;
external_order_no?: string;
service_provider: string;
service_provider_text: string;
price_package_name: string;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { CopyDocument } from "@element-plus/icons-vue";
import {
adminApi,
type EnterpriseCustomer,
@@ -210,6 +211,49 @@ async function resetSecret(row: EnterpriseCustomerApp) {
}
}
function copySecretFallback(value: string) {
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "readonly");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
try {
const copied = document.execCommand("copy");
if (!copied) {
throw new Error("copy command failed");
}
} finally {
document.body.removeChild(input);
}
}
async function copySecret() {
const value = oneTimeSecret.value;
if (!value) {
ElMessage.warning("Secret 为空");
return;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
} else {
copySecretFallback(value);
}
ElMessage.success("Secret 已复制");
} catch (error) {
try {
copySecretFallback(value);
ElMessage.success("Secret 已复制");
} catch (fallbackError) {
console.error(fallbackError || error);
ElMessage.error("Secret 复制失败");
}
}
}
async function resendEvent(row: EnterpriseOrderEvent) {
try {
const response = await adminApi.resendCustomerEvent(row.id);
@@ -485,7 +529,11 @@ onMounted(fetchCustomers);
<el-dialog v-model="secretDialogVisible" title="应用 Secret" width="620px">
<el-alert type="warning" show-icon :closable="false" title="Secret 只展示一次,关闭后无法再次查看。" />
<el-input v-model="oneTimeSecret" readonly style="margin-top: 16px" />
<el-input v-model="oneTimeSecret" readonly class="secret-input">
<template #append>
<el-button :icon="CopyDocument" @click="copySecret">复制</el-button>
</template>
</el-input>
<template #footer>
<el-button type="primary" @click="secretDialogVisible = false">已保存</el-button>
</template>
@@ -509,4 +557,8 @@ onMounted(fetchCustomers);
.detail-url {
overflow-wrap: anywhere;
}
.secret-input {
margin-top: 16px;
}
</style>

View File

@@ -42,6 +42,7 @@ const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
const manualAddressRecognitionText = ref("");
const keyword = ref("");
const externalOrderNo = ref("");
const trackingNo = ref("");
const userMobile = ref("");
const serviceProvider = ref("");
@@ -203,6 +204,7 @@ async function fetchOrders() {
try {
const response = await adminApi.getOrders({
keyword: keyword.value,
external_order_no: externalOrderNo.value,
tracking_no: trackingNo.value,
user_mobile: userMobile.value,
service_provider: serviceProvider.value,
@@ -535,6 +537,7 @@ onMounted(fetchOrders);
<el-card class="panel-card" shadow="never">
<div class="filters-row">
<el-input v-model="keyword" placeholder="搜索订单号 / 鉴定单号 / 商品名称" clearable style="width: 320px" />
<el-input v-model="externalOrderNo" placeholder="搜索客户单号" clearable style="width: 180px" />
<el-input v-model="trackingNo" placeholder="搜索快递单号" clearable style="width: 180px" />
<el-input v-model="userMobile" placeholder="搜索用户电话" clearable style="width: 180px" />
<el-select v-model="serviceProvider" placeholder="服务类型" style="width: 160px">
@@ -563,6 +566,7 @@ onMounted(fetchOrders);
<el-table-column label="下单渠道" min-width="150">
<template #default="{ row }">
<span>{{ row.source_channel_text }}</span>
<div v-if="row.external_order_no" class="table-subtext">客户单号{{ row.external_order_no }}</div>
<div v-if="row.source_customer_id" class="table-subtext">客户ID{{ row.source_customer_id }}</div>
</template>
</el-table-column>
@@ -664,6 +668,10 @@ onMounted(fetchOrders);
<div class="order-detail-item__label">大客户客户 ID</div>
<div class="order-detail-item__value">{{ detail.order_info.source_customer_id }}</div>
</div>
<div class="order-detail-item" v-if="detail.order_info.external_order_no">
<div class="order-detail-item__label">客户单号</div>
<div class="order-detail-item__value">{{ detail.order_info.external_order_no }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">当前状态</div>
<div class="order-detail-item__value"><OrderStatusTag :status="detail.order_info.display_status" /></div>

View File

@@ -200,6 +200,37 @@ POST /api/open/v1/orders
| `tracking_no` | string | 否 | 寄入运单号,可替代 `inbound_logistics.tracking_no` |
| `extra_info` | object | 否 | 购买、成色、附件、备注等扩展信息 |
### 5.3 单独设置寄回地址
```text
POST /api/open/v1/orders/return-address
```
第三方可以在建单后单独补录或更新寄回地址。订单已生成回寄运单后,不允许再修改。
### 5.4 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `external_order_no` | string | 是 | 第三方订单号 |
| `return_address` | object | 是 | 寄回地址,字段要求同创建订单接口 |
### 5.5 请求示例
```json
{
"external_order_no": "THIRD202605080001",
"return_address": {
"consignee": "张三",
"mobile": "13800138000",
"province": "浙江省",
"city": "杭州市",
"district": "西湖区",
"detail_address": "文三路 1 号"
}
}
```
### 5.2 可选对象字段
`product_info` 支持:
@@ -452,6 +483,15 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
"latest_desc": "客户已提交寄送运单:顺丰速运 SF1234567890等待鉴定中心签收。",
"latest_time": "2026-05-08 12:00:00"
},
"return_address": {
"consignee": "张三",
"mobile": "13800138000",
"province": "浙江省",
"city": "杭州市",
"district": "西湖区",
"detail_address": "文三路 1 号",
"full_address": "浙江省杭州市西湖区文三路 1 号"
},
"return_logistics": null,
"report_summary": {
"report_no": "R202605080001",
@@ -617,6 +657,7 @@ POST /api/open/v1/orders/shipping
2. 第三方完成签名调试。
3. 第三方调用套餐获取接口,确认可用套餐和 `price_package_code`
4. 第三方调用创建订单接口。最小只传 `external_order_no` 即可;如需要减少后续人工补录,建议同步传 `price_package_code``product_info``return_address``materials``inbound_logistics`
5. 商品实际寄出后,第三方调用发货通知接口提交 `express_company``tracking_no`
6. 第三方可通过查询接口主动查询订单状态
7. 如启用 webhook平台在订单状态变化时主动通知第三方。
5. 如建单时未提供寄回地址,或后续需要变更,可调用寄回地址接口补录或更新 `return_address`
6. 商品实际寄出后,第三方调用发货通知接口提交 `express_company``tracking_no`
7. 第三方可通过查询接口主动查询订单状态,并核对 `return_address`、物流和报告结果。
8. 如启用 webhook平台在订单状态变化时主动通知第三方。

View File

@@ -18,6 +18,7 @@ class OrdersController
public function index(Request $request)
{
$keyword = trim((string)$request->input('keyword', ''));
$externalOrderNo = trim((string)$request->input('external_order_no', ''));
$trackingNo = trim((string)$request->input('tracking_no', ''));
$userMobile = trim((string)$request->input('user_mobile', ''));
$status = trim((string)$request->input('status', ''));
@@ -30,10 +31,12 @@ class OrdersController
$query = Db::name('orders')
->alias('o')
->leftJoin('order_products p', 'p.order_id = o.id')
->leftJoin('enterprise_customer_order_refs ecor', 'ecor.order_id = o.id')
->field([
'o.id',
'o.order_no',
'o.appraisal_no',
'ecor.external_order_no',
'o.service_provider',
'o.order_status',
'o.display_status',
@@ -64,6 +67,12 @@ class OrdersController
});
}
if ($externalOrderNo !== '') {
$query->whereRaw('ecor.external_order_no LIKE :external_order_no', [
'external_order_no' => "%{$externalOrderNo}%",
]);
}
if ($trackingNo !== '') {
$query->whereRaw(
"EXISTS (SELECT 1 FROM order_logistics ol WHERE ol.order_id = o.id AND ol.logistics_type IN ('send_to_center', 'return_to_user') AND ol.tracking_no LIKE :tracking_no)",
@@ -155,6 +164,7 @@ class OrdersController
'id' => $orderId,
'order_no' => $item['order_no'],
'appraisal_no' => $item['appraisal_no'],
'external_order_no' => (string)($item['external_order_no'] ?? ''),
'product_name' => $item['product_name'] ?: '待完善物品信息',
'category_name' => $item['category_name'] ?: '',
'brand_name' => $item['brand_name'] ?: '',
@@ -255,6 +265,7 @@ class OrdersController
->where('order_id', $id)
->order('id', 'desc')
->find();
$enterpriseOrderRef = Db::name('enterprise_customer_order_refs')->where('order_id', $id)->find();
$timeline = Db::name('order_timelines')
->where('order_id', $id)
->order('occurred_at', 'asc')
@@ -343,6 +354,7 @@ class OrdersController
'id' => (int)$order['id'],
'order_no' => $order['order_no'],
'appraisal_no' => $order['appraisal_no'],
'external_order_no' => (string)($enterpriseOrderRef['external_order_no'] ?? ''),
'service_provider' => $order['service_provider'],
'service_provider_text' => $order['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
'price_package_name' => (string)($order['price_package_name'] ?? ''),

View File

@@ -2,6 +2,8 @@
namespace app\controller\app;
use app\support\ContentService;
use app\support\FileStorageService;
use support\Request;
use support\think\Db;
@@ -9,11 +11,19 @@ class CatalogController
{
public function categories(Request $request)
{
$categoryVisuals = $this->categoryVisualMap($request);
$list = Db::name('catalog_categories')
->field(['id AS category_id', 'name AS category_name', 'code AS category_code'])
->where('is_enabled', 1)
->order('sort_order', 'asc')
->select()
->map(function ($item) use ($categoryVisuals) {
$codeKey = $this->categoryMatchKey((string)$item['category_code']);
$nameKey = $this->categoryMatchKey((string)$item['category_name']);
$item['image_url'] = $categoryVisuals['code:' . $codeKey] ?? $categoryVisuals['name:' . $nameKey] ?? '';
return $item;
})
->toArray();
return api_success(['list' => $list]);
@@ -39,4 +49,45 @@ class CatalogController
]);
}
private function categoryVisualMap(Request $request): array
{
$items = (new ContentService())->getHomeConfig()['category_visuals'] ?? [];
if (!is_array($items)) {
return [];
}
$map = [];
$storage = new FileStorageService();
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$imageUrl = trim((string)($item['image_url'] ?? ''));
if ($imageUrl === '') {
continue;
}
$imageUrl = $storage->normalizeUrl($imageUrl, $request);
$categoryCode = $this->categoryMatchKey((string)($item['category_code'] ?? ''));
if ($categoryCode !== '') {
$map['code:' . $categoryCode] = $imageUrl;
}
$categoryName = $this->categoryMatchKey((string)($item['category_name'] ?? ''));
if ($categoryName !== '') {
$map['name:' . $categoryName] = $imageUrl;
}
}
return $map;
}
private function categoryMatchKey(string $value): string
{
$value = trim($value);
$normalized = preg_replace('/[\s\p{Cf}]+/u', '', $value);
return strtolower($normalized ?? $value);
}
}

View File

@@ -65,6 +65,34 @@ class OrdersController
return api_success($result, '运单已提交');
}
public function saveReturnAddress(Request $request)
{
try {
$auth = (new EnterpriseOpenApiAuthService())->authenticate($request);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 401);
}
$payload = json_decode($request->rawBody(), true);
if (!is_array($payload)) {
return api_error('请求体必须是合法 JSON 对象', 422);
}
try {
$result = (new EnterpriseOrderService())->saveReturnAddress($auth['customer'], $payload);
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 404);
} catch (\Throwable $e) {
return api_error('寄回地址保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success($result, '寄回地址已保存');
}
public function servicePricePackages(Request $request)
{
try {

View File

@@ -299,6 +299,93 @@ class EnterpriseOrderService
];
}
public function saveReturnAddress(array $customer, array $payload): array
{
$externalOrderNo = trim((string)($payload['external_order_no'] ?? ''));
if ($externalOrderNo === '') {
throw new \InvalidArgumentException('external_order_no 不能为空');
}
$returnAddress = $this->normalizeReturnAddress((array)($payload['return_address'] ?? []));
if (!$returnAddress) {
throw new \InvalidArgumentException('return_address 不能为空');
}
$ref = Db::name('enterprise_customer_order_refs')
->where('customer_id', (int)$customer['id'])
->where('external_order_no', $externalOrderNo)
->find();
if (!$ref) {
throw new \RuntimeException('订单不存在');
}
$order = Db::name('orders')->where('id', (int)$ref['order_id'])->find();
if (!$order) {
throw new \RuntimeException('订单不存在');
}
$returnLogistics = Db::name('order_logistics')
->where('order_id', (int)$order['id'])
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->find();
if (!empty($returnLogistics['tracking_no'])) {
throw new \InvalidArgumentException('回寄运单已生成,当前不可再修改寄回地址');
}
$existing = Db::name('order_return_addresses')->where('order_id', (int)$order['id'])->find();
$now = date('Y-m-d H:i:s');
$updated = (bool)$existing;
$snapshot = array_merge($returnAddress, [
'user_address_id' => null,
]);
Db::startTrans();
try {
if ($existing) {
Db::name('order_return_addresses')->where('order_id', (int)$order['id'])->update(array_merge($snapshot, [
'updated_at' => $now,
]));
$nodeText = '已更新寄回地址';
} else {
Db::name('order_return_addresses')->insert(array_merge($snapshot, [
'order_id' => (int)$order['id'],
'created_at' => $now,
'updated_at' => $now,
]));
$nodeText = '已确认寄回地址';
}
Db::name('order_timelines')->insert([
'order_id' => (int)$order['id'],
'node_code' => 'return_address_selected',
'node_text' => $nodeText,
'node_desc' => sprintf(
'大客户已确认寄回地址:%s%s%s%s',
$returnAddress['province'],
$returnAddress['city'],
$returnAddress['district'],
$returnAddress['detail_address']
),
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'updated' => $updated,
'return_address' => $this->formatReturnAddress($snapshot),
'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']),
];
}
public function buildOrderProgress(int $customerId, array $ref, string $customerCode = ''): array
{
$order = Db::name('orders')->where('id', (int)$ref['order_id'])->find();
@@ -309,6 +396,7 @@ class EnterpriseOrderService
$timeline = Db::name('order_timelines')->where('order_id', (int)$order['id'])->order('occurred_at', 'asc')->select()->toArray();
$sendLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
$returnLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
$returnAddress = Db::name('order_return_addresses')->where('order_id', (int)$order['id'])->find();
$report = Db::name('reports')
->where('order_id', (int)$order['id'])
->where('report_status', 'published')
@@ -339,6 +427,7 @@ class EnterpriseOrderService
'occurred_at' => (string)$item['occurred_at'],
], $timeline),
'inbound_logistics' => $this->formatLogistics($sendLogistics),
'return_address' => $returnAddress ? $this->formatReturnAddress($returnAddress) : null,
'return_logistics' => $this->formatLogistics($returnLogistics),
'report_summary' => $report ? [
'report_no' => (string)$report['report_no'],
@@ -586,4 +675,23 @@ class EnterpriseOrderService
'latest_time' => (string)($logistics['latest_time'] ?? ''),
];
}
private function formatReturnAddress(array $item): array
{
return [
'consignee' => (string)($item['consignee'] ?? ''),
'mobile' => (string)($item['mobile'] ?? ''),
'province' => (string)($item['province'] ?? ''),
'city' => (string)($item['city'] ?? ''),
'district' => (string)($item['district'] ?? ''),
'detail_address' => (string)($item['detail_address'] ?? ''),
'full_address' => trim(sprintf(
'%s%s%s%s',
$item['province'] ?? '',
$item['city'] ?? '',
$item['district'] ?? '',
$item['detail_address'] ?? ''
)),
];
}
}

View File

@@ -212,6 +212,7 @@ Route::post('/api/app/address/default', [AppAddressesController::class, 'setDefa
Route::post('/api/app/address/delete', [AppAddressesController::class, 'delete']);
Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']);
Route::post('/api/open/v1/orders/return-address', [OpenOrdersController::class, 'saveReturnAddress']);
Route::post('/api/open/v1/orders/shipping', [OpenOrdersController::class, 'shipping']);
Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']);
Route::get('/api/open/v1/orders/{external_order_no}', [OpenOrdersController::class, 'detail']);

View File

@@ -0,0 +1,699 @@
<?php
declare(strict_types=1);
return [
[
'code' => 'lv',
'name' => '路易威登',
'en_name' => 'Louis Vuitton',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 10,
'source_tags' => ['interbrand', 'lvmh'],
],
[
'code' => 'chanel',
'name' => '香奈儿',
'en_name' => 'Chanel',
'category_codes' => ['luxury_bag', 'jewelry', 'beauty'],
'category_names' => ['奢侈品箱包', '首饰配饰', '高端美妆'],
'sort_order' => 20,
'source_tags' => ['interbrand'],
],
[
'code' => 'hermes',
'name' => '爱马仕',
'en_name' => 'Hermes',
'category_codes' => ['luxury_bag', 'jewelry'],
'category_names' => ['奢侈品箱包', '首饰配饰'],
'sort_order' => 30,
'source_tags' => ['interbrand'],
],
[
'code' => 'gucci',
'name' => '古驰',
'en_name' => 'Gucci',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 40,
'source_tags' => ['interbrand', 'kering'],
],
[
'code' => 'dior',
'name' => '迪奥',
'en_name' => 'Dior',
'category_codes' => ['luxury_bag', 'beauty'],
'category_names' => ['奢侈品箱包', '潮流服饰', '高端美妆'],
'sort_order' => 50,
'source_tags' => ['interbrand', 'lvmh'],
],
[
'code' => 'prada',
'name' => '普拉达',
'en_name' => 'Prada',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 60,
'source_tags' => ['interbrand', 'lyst'],
],
[
'code' => 'celine',
'name' => '思琳',
'en_name' => 'Celine',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 70,
'source_tags' => ['lvmh'],
],
[
'code' => 'loewe',
'name' => '罗意威',
'en_name' => 'Loewe',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 80,
'source_tags' => ['lvmh', 'lyst'],
],
[
'code' => 'fendi',
'name' => '芬迪',
'en_name' => 'Fendi',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 90,
'source_tags' => ['lvmh'],
],
[
'code' => 'balenciaga',
'name' => '巴黎世家',
'en_name' => 'Balenciaga',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 100,
'source_tags' => ['kering', 'lyst'],
],
[
'code' => 'bottega_veneta',
'name' => '葆蝶家',
'en_name' => 'Bottega Veneta',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 110,
'source_tags' => ['kering'],
],
[
'code' => 'saint_laurent',
'name' => '圣罗兰',
'en_name' => 'Saint Laurent',
'category_codes' => ['luxury_bag', 'beauty'],
'category_names' => ['奢侈品箱包', '潮流服饰', '高端美妆'],
'sort_order' => 120,
'source_tags' => ['kering', 'loreal'],
],
[
'code' => 'burberry',
'name' => '博柏利',
'en_name' => 'Burberry',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 130,
'source_tags' => ['interbrand'],
],
[
'code' => 'coach',
'name' => '蔻驰',
'en_name' => 'Coach',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 140,
'source_tags' => ['interbrand'],
],
[
'code' => 'michael_kors',
'name' => '迈克高仕',
'en_name' => 'Michael Kors',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 150,
'source_tags' => ['interbrand'],
],
[
'code' => 'miu_miu',
'name' => '缪缪',
'en_name' => 'Miu Miu',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 160,
'source_tags' => ['lyst'],
],
[
'code' => 'givenchy',
'name' => '纪梵希',
'en_name' => 'Givenchy',
'category_codes' => ['luxury_bag', 'beauty'],
'category_names' => ['奢侈品箱包', '潮流服饰', '高端美妆'],
'sort_order' => 170,
'source_tags' => ['lvmh'],
],
[
'code' => 'valentino',
'name' => '华伦天奴',
'en_name' => 'Valentino',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 180,
'source_tags' => ['lyst'],
],
[
'code' => 'versace',
'name' => '范思哲',
'en_name' => 'Versace',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 190,
'source_tags' => ['interbrand'],
],
[
'code' => 'alexander_mcqueen',
'name' => '亚历山大麦昆',
'en_name' => 'Alexander McQueen',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 200,
'source_tags' => ['kering'],
],
[
'code' => 'marc_jacobs',
'name' => '马克雅可布',
'en_name' => 'Marc Jacobs',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 210,
'source_tags' => ['lvmh'],
],
[
'code' => 'nike',
'name' => '耐克',
'en_name' => 'Nike',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 300,
'source_tags' => ['interbrand', 'stockx'],
],
[
'code' => 'jordan',
'name' => '乔丹',
'en_name' => 'Jordan',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 310,
'source_tags' => ['stockx'],
],
[
'code' => 'adidas',
'name' => '阿迪达斯',
'en_name' => 'Adidas',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 320,
'source_tags' => ['interbrand', 'stockx'],
],
[
'code' => 'new_balance',
'name' => '新百伦',
'en_name' => 'New Balance',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 330,
'source_tags' => ['stockx'],
],
[
'code' => 'asics',
'name' => '亚瑟士',
'en_name' => 'ASICS',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 340,
'source_tags' => ['stockx'],
],
[
'code' => 'puma',
'name' => '彪马',
'en_name' => 'Puma',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 350,
'source_tags' => ['interbrand'],
],
[
'code' => 'converse',
'name' => '匡威',
'en_name' => 'Converse',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 360,
'source_tags' => ['stockx'],
],
[
'code' => 'vans',
'name' => '范斯',
'en_name' => 'Vans',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 370,
'source_tags' => ['stockx'],
],
[
'code' => 'reebok',
'name' => '锐步',
'en_name' => 'Reebok',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 380,
'source_tags' => ['stockx'],
],
[
'code' => 'salomon',
'name' => '萨洛蒙',
'en_name' => 'Salomon',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 390,
'source_tags' => ['lyst'],
],
[
'code' => 'on_running',
'name' => '昂跑',
'en_name' => 'On',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 400,
'source_tags' => ['lyst'],
],
[
'code' => 'supreme',
'name' => 'Supreme',
'en_name' => 'Supreme',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 430,
'source_tags' => ['stockx'],
],
[
'code' => 'stussy',
'name' => 'Stussy',
'en_name' => 'Stussy',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 440,
'source_tags' => ['stockx'],
],
[
'code' => 'off_white',
'name' => 'Off-White',
'en_name' => 'Off-White',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 450,
'source_tags' => ['lyst'],
],
[
'code' => 'fear_of_god',
'name' => 'Fear of God',
'en_name' => 'Fear of God',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 460,
'source_tags' => ['stockx'],
],
[
'code' => 'arc_teryx',
'name' => '始祖鸟',
'en_name' => 'Arc\'teryx',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 470,
'source_tags' => ['lyst'],
],
[
'code' => 'moncler',
'name' => '盟可睐',
'en_name' => 'Moncler',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 480,
'source_tags' => ['lyst'],
],
[
'code' => 'rolex',
'name' => '劳力士',
'en_name' => 'Rolex',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 500,
'source_tags' => ['interbrand'],
],
[
'code' => 'omega',
'name' => '欧米茄',
'en_name' => 'Omega',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 510,
'source_tags' => ['interbrand'],
],
[
'code' => 'cartier',
'name' => '卡地亚',
'en_name' => 'Cartier',
'category_codes' => ['watch', 'jewelry'],
'category_names' => ['腕表', '首饰配饰'],
'sort_order' => 520,
'source_tags' => ['richemont', 'interbrand'],
],
[
'code' => 'patek_philippe',
'name' => '百达翡丽',
'en_name' => 'Patek Philippe',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 530,
'source_tags' => ['interbrand'],
],
[
'code' => 'audemars_piguet',
'name' => '爱彼',
'en_name' => 'Audemars Piguet',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 540,
'source_tags' => ['interbrand'],
],
[
'code' => 'iwc',
'name' => '万国',
'en_name' => 'IWC Schaffhausen',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 550,
'source_tags' => ['richemont'],
],
[
'code' => 'jaeger_lecoultre',
'name' => '积家',
'en_name' => 'Jaeger-LeCoultre',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 560,
'source_tags' => ['richemont'],
],
[
'code' => 'vacheron_constantin',
'name' => '江诗丹顿',
'en_name' => 'Vacheron Constantin',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 570,
'source_tags' => ['richemont'],
],
[
'code' => 'panerai',
'name' => '沛纳海',
'en_name' => 'Panerai',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 580,
'source_tags' => ['richemont'],
],
[
'code' => 'tag_heuer',
'name' => '泰格豪雅',
'en_name' => 'TAG Heuer',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 590,
'source_tags' => ['lvmh'],
],
[
'code' => 'longines',
'name' => '浪琴',
'en_name' => 'Longines',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 600,
'source_tags' => ['interbrand'],
],
[
'code' => 'tissot',
'name' => '天梭',
'en_name' => 'Tissot',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 610,
'source_tags' => ['interbrand'],
],
[
'code' => 'tiffany_co',
'name' => '蒂芙尼',
'en_name' => 'Tiffany & Co.',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 700,
'source_tags' => ['interbrand', 'lvmh'],
],
[
'code' => 'van_cleef_arpels',
'name' => '梵克雅宝',
'en_name' => 'Van Cleef & Arpels',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 710,
'source_tags' => ['richemont'],
],
[
'code' => 'bulgari',
'name' => '宝格丽',
'en_name' => 'Bulgari',
'category_codes' => ['jewelry', 'watch'],
'category_names' => ['首饰配饰', '腕表'],
'sort_order' => 720,
'source_tags' => ['lvmh'],
],
[
'code' => 'chaumet',
'name' => '尚美巴黎',
'en_name' => 'Chaumet',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 730,
'source_tags' => ['lvmh'],
],
[
'code' => 'boucheron',
'name' => '宝诗龙',
'en_name' => 'Boucheron',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 740,
'source_tags' => ['kering'],
],
[
'code' => 'pomellato',
'name' => '宝曼兰朵',
'en_name' => 'Pomellato',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 750,
'source_tags' => ['kering'],
],
[
'code' => 'chopard',
'name' => '萧邦',
'en_name' => 'Chopard',
'category_codes' => ['jewelry', 'watch'],
'category_names' => ['首饰配饰', '腕表'],
'sort_order' => 760,
'source_tags' => ['interbrand'],
],
[
'code' => 'swarovski',
'name' => '施华洛世奇',
'en_name' => 'Swarovski',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 770,
'source_tags' => ['interbrand'],
],
[
'code' => 'pandora',
'name' => '潘多拉',
'en_name' => 'Pandora',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 780,
'source_tags' => ['interbrand'],
],
[
'code' => 'lancome',
'name' => '兰蔻',
'en_name' => 'Lancome',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 900,
'source_tags' => ['loreal'],
],
[
'code' => 'ysl_beauty',
'name' => '圣罗兰美妆',
'en_name' => 'Yves Saint Laurent Beauty',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 910,
'source_tags' => ['loreal'],
],
[
'code' => 'armani_beauty',
'name' => '阿玛尼美妆',
'en_name' => 'Armani Beauty',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 920,
'source_tags' => ['loreal'],
],
[
'code' => 'kiehls',
'name' => '科颜氏',
'en_name' => 'Kiehl\'s',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 930,
'source_tags' => ['loreal'],
],
[
'code' => 'shiseido',
'name' => '资生堂',
'en_name' => 'Shiseido',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 940,
'source_tags' => ['interbrand'],
],
[
'code' => 'sk_ii',
'name' => 'SK-II',
'en_name' => 'SK-II',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 950,
'source_tags' => ['interbrand'],
],
[
'code' => 'la_mer',
'name' => '海蓝之谜',
'en_name' => 'La Mer',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 960,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'estee_lauder',
'name' => '雅诗兰黛',
'en_name' => 'Estee Lauder',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 970,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'clinique',
'name' => '倩碧',
'en_name' => 'Clinique',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 980,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'mac',
'name' => '魅可',
'en_name' => 'MAC',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 990,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'bobbi_brown',
'name' => '芭比波朗',
'en_name' => 'Bobbi Brown',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 1000,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'tom_ford_beauty',
'name' => '汤姆福特美妆',
'en_name' => 'Tom Ford Beauty',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 1010,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'nars',
'name' => 'NARS',
'en_name' => 'NARS',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 1020,
'source_tags' => ['interbrand'],
],
[
'code' => 'apple',
'name' => '苹果',
'en_name' => 'Apple',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1200,
'source_tags' => ['interbrand'],
],
[
'code' => 'samsung',
'name' => '三星',
'en_name' => 'Samsung',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1210,
'source_tags' => ['interbrand'],
],
[
'code' => 'huawei',
'name' => '华为',
'en_name' => 'Huawei',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1220,
'source_tags' => ['interbrand'],
],
[
'code' => 'xiaomi',
'name' => '小米',
'en_name' => 'Xiaomi',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1230,
'source_tags' => ['interbrand'],
],
[
'code' => 'sony',
'name' => '索尼',
'en_name' => 'Sony',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1240,
'source_tags' => ['interbrand'],
],
];

View File

@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
const LEGACY_CODE_ALIASES = [
'tiffany_co' => ['1'],
];
$dryRun = in_array('--dry-run', $argv, true);
$brandFile = dirname(__DIR__) . '/resources/catalog/known_brands.php';
if (!is_file($brandFile)) {
fwrite(STDERR, "Known brand file not found: {$brandFile}\n");
exit(1);
}
$brands = require $brandFile;
if (!is_array($brands)) {
fwrite(STDERR, "Known brand file must return an array.\n");
exit(1);
}
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$_ENV['DB_DATABASE'] ?? '',
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$_ENV['DB_USERNAME'] ?? '',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function normalize_list(mixed $value): array
{
if (!is_array($value)) {
return [];
}
$items = [];
foreach ($value as $item) {
$text = trim((string)$item);
if ($text !== '') {
$items[] = $text;
}
}
return array_values(array_unique($items));
}
function decode_json_list(mixed $value): array
{
if (is_array($value)) {
return normalize_list($value);
}
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return normalize_list(is_array($decoded) ? $decoded : []);
}
function stable_json_list(array $value): string
{
$items = normalize_list($value);
sort($items, SORT_STRING);
return json_encode($items, JSON_UNESCAPED_UNICODE);
}
function load_enabled_categories(PDO $pdo): array
{
$rows = $pdo->query(
"SELECT id, name, code, supported_service_types
FROM catalog_categories
WHERE is_enabled = 1
ORDER BY sort_order ASC, id ASC"
)->fetchAll();
$byCode = [];
$byName = [];
$byId = [];
foreach ($rows as $row) {
$category = [
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'code' => (string)$row['code'],
'supported_service_types' => decode_json_list($row['supported_service_types'] ?? null),
];
$byId[$category['id']] = $category;
$byCode[strtolower($category['code'])] = $category;
$byName[$category['name']] = $category;
}
return [$byCode, $byName, $byId];
}
function match_categories(array $brand, array $categoriesByCode, array $categoriesByName): array
{
$matched = [];
foreach (normalize_list($brand['category_codes'] ?? []) as $code) {
$key = strtolower($code);
if (isset($categoriesByCode[$key])) {
$matched[$categoriesByCode[$key]['id']] = $categoriesByCode[$key];
}
}
foreach (normalize_list($brand['category_names'] ?? []) as $name) {
if (isset($categoriesByName[$name])) {
$matched[$categoriesByName[$name]['id']] = $categoriesByName[$name];
}
}
return $matched;
}
function infer_supported_service_types(array $categories): array
{
$serviceTypes = [];
foreach ($categories as $category) {
foreach ($category['supported_service_types'] as $serviceType) {
$serviceTypes[$serviceType] = $serviceType;
}
}
if (!$serviceTypes) {
$serviceTypes['anxinyan'] = 'anxinyan';
}
return array_values($serviceTypes);
}
function find_existing_brand(PDO $pdo, string $code): ?array
{
$stmt = $pdo->prepare('SELECT * FROM catalog_brands WHERE code = ? LIMIT 1');
$stmt->execute([$code]);
$row = $stmt->fetch();
if ($row) {
return $row;
}
foreach (LEGACY_CODE_ALIASES[$code] ?? [] as $legacyCode) {
$stmt->execute([$legacyCode]);
$legacy = $stmt->fetch();
if ($legacy) {
return $legacy;
}
}
return null;
}
function existing_relation_ids(PDO $pdo, int $brandId): array
{
$stmt = $pdo->prepare('SELECT category_id FROM catalog_brand_categories WHERE brand_id = ?');
$stmt->execute([$brandId]);
$ids = [];
foreach ($stmt->fetchAll() as $row) {
$ids[(int)$row['category_id']] = true;
}
return $ids;
}
[$categoriesByCode, $categoriesByName, $categoriesById] = load_enabled_categories($pdo);
$stats = [
'brands_total' => count($brands),
'inserted' => 0,
'updated' => 0,
'enabled_existing' => 0,
'legacy_code_repaired' => 0,
'relations_inserted' => 0,
'skipped_no_enabled_category' => 0,
'skipped_invalid' => 0,
];
$now = date('Y-m-d H:i:s');
$insertBrand = $pdo->prepare(
'INSERT INTO catalog_brands (name, en_name, code, logo, sort_order, is_enabled, supported_service_types, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)'
);
$updateBrand = $pdo->prepare(
'UPDATE catalog_brands
SET name = ?, en_name = ?, code = ?, sort_order = ?, is_enabled = 1, supported_service_types = ?, updated_at = ?
WHERE id = ?'
);
$insertRelation = $pdo->prepare(
'INSERT INTO catalog_brand_categories (brand_id, category_id, created_at)
VALUES (?, ?, ?)'
);
if (!$dryRun) {
$pdo->beginTransaction();
}
try {
foreach ($brands as $brand) {
$code = trim((string)($brand['code'] ?? ''));
$name = trim((string)($brand['name'] ?? ''));
$enName = trim((string)($brand['en_name'] ?? ''));
if ($code === '' || $name === '' || $enName === '') {
$stats['skipped_invalid']++;
continue;
}
$matchedCategories = match_categories($brand, $categoriesByCode, $categoriesByName);
if (!$matchedCategories) {
$stats['skipped_no_enabled_category']++;
continue;
}
$serviceTypes = infer_supported_service_types($matchedCategories);
$serviceTypesJson = stable_json_list($serviceTypes);
$sortOrder = (int)($brand['sort_order'] ?? 0);
$existing = find_existing_brand($pdo, $code);
if ($existing) {
$brandId = (int)$existing['id'];
$wasDisabled = (int)$existing['is_enabled'] !== 1;
$wasLegacyCode = (string)$existing['code'] !== $code;
$existingJson = stable_json_list(decode_json_list($existing['supported_service_types'] ?? null));
$needsUpdate = $wasDisabled
|| $wasLegacyCode
|| (string)$existing['name'] !== $name
|| (string)$existing['en_name'] !== $enName
|| (int)$existing['sort_order'] !== $sortOrder
|| $existingJson !== $serviceTypesJson;
if ($needsUpdate) {
$stats['updated']++;
if ($wasDisabled) {
$stats['enabled_existing']++;
}
if ($wasLegacyCode) {
$stats['legacy_code_repaired']++;
}
if (!$dryRun) {
$updateBrand->execute([$name, $enName, $code, $sortOrder, $serviceTypesJson, $now, $brandId]);
}
}
} else {
$stats['inserted']++;
if ($dryRun) {
$brandId = 0;
} else {
$insertBrand->execute([$name, $enName, $code, '', $sortOrder, $serviceTypesJson, $now, $now]);
$brandId = (int)$pdo->lastInsertId();
}
}
$existingCategoryIds = $brandId > 0 ? existing_relation_ids($pdo, $brandId) : [];
foreach (array_keys($matchedCategories) as $categoryId) {
if (isset($existingCategoryIds[(int)$categoryId])) {
continue;
}
$stats['relations_inserted']++;
if (!$dryRun && $brandId > 0) {
$insertRelation->execute([$brandId, (int)$categoryId, $now]);
}
}
}
if (!$dryRun) {
$pdo->commit();
}
} catch (Throwable $e) {
if (!$dryRun && $pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "IMPORT_KNOWN_BRANDS_FAILED\n");
fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1);
}
echo $dryRun ? "DRY_RUN\n" : "IMPORT_KNOWN_BRANDS_OK\n";
echo "ENABLED_CATEGORIES=" . count($categoriesById) . PHP_EOL;
foreach ($stats as $key => $value) {
echo strtoupper($key) . '=' . $value . PHP_EOL;
}

View File

@@ -1,3 +1,3 @@
VITE_API_BASE_URL=https://test.api.anxinjianyan.com
VITE_APP_ENV=test
VITE_API_BASE_URL=http://127.0.0.1:8788
VITE_APP_ENV=development
VITE_APP_TITLE=安心验

View File

@@ -14,6 +14,7 @@ export interface CategoryOption {
category_id: number;
category_name: string;
category_code: string;
image_url?: string;
}
export interface UploadItem {

View File

@@ -50,7 +50,8 @@
{
"path": "pages/order/detail",
"style": {
"navigationBarTitleText": "订单详情"
"navigationBarTitleText": "订单详情",
"navigationStyle": "custom"
}
},
{

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { appraisalApi } from "../../api/appraisal";
import { appraisalApi, type AppraisalServicePackage, type PreviewData } from "../../api/appraisal";
import { appApi, type UserAddressItem } from "../../api/app";
import { useAppraisalStore } from "../../stores/appraisal";
import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow";
@@ -15,18 +15,24 @@ const submitting = ref(false);
const addressSheetVisible = ref(false);
const addressesLoading = ref(false);
const addressOptions = ref<UserAddressItem[]>([]);
const packageOptions = ref<AppraisalServicePackage[]>([]);
const packageOptionsLoading = ref(false);
const packageOptionsError = ref("");
const packageUpdating = ref(false);
const selectedReturnAddress = computed(() => store.returnAddress);
const recentCreatedAddressStorageKey = "anxinyan_recent_created_address_id";
const serviceProviderText = computed(() => preview.value?.service_summary.service_provider_text || "安心验鉴定");
const currentServiceProvider = computed(() => preview.value?.service_summary.service_provider || store.serviceProvider || "anxinyan");
const packageNameText = computed(() => preview.value?.service_summary.price_package_name || store.pricePackageName || "");
const selectedPackageId = computed(() => Number(preview.value?.service_summary.price_package_id || store.pricePackageId || 0));
const categoryText = computed(() => preview.value?.product_summary.category_name || store.product.categoryName || "-");
const brandText = computed(() => preview.value?.product_summary.brand_name || store.product.brandName || "未填写");
const productNameText = computed(() => preview.value?.product_summary.product_name || `${categoryText.value} ${brandText.value === "未填写" ? "" : brandText.value}`.trim());
const serviceFeeText = computed(() => formatMoney(preview.value?.fee_detail.service_fee || 0));
const discountFeeText = computed(() => formatMoney(preview.value?.fee_detail.discount_fee || 0));
const payAmountText = computed(() => formatMoney(preview.value?.fee_detail.pay_amount || 0));
const canSubmit = computed(() => Boolean(store.draftId && store.returnAddress.id && !loading.value && !submitting.value));
const canSubmit = computed(() => Boolean(store.draftId && store.returnAddress.id && !loading.value && !submitting.value && !packageUpdating.value));
function formatMoney(value: number | string) {
const amount = Number(value || 0);
@@ -34,6 +40,20 @@ function formatMoney(value: number | string) {
return amount % 1 === 0 ? String(amount) : amount.toFixed(2);
}
function normalizeProvider(value = "") {
return value === "zhongjian" ? "zhongjian" : "anxinyan";
}
function syncPackageFromPreview(data: PreviewData) {
store.setServiceProvider(data.service_summary.service_provider || store.serviceProvider);
store.setPricePackage({
id: Number(data.service_summary.price_package_id || 0),
packageName: data.service_summary.price_package_name || "",
packageCode: data.service_summary.price_package_code || "",
price: Number(data.fee_detail.service_fee || 0),
});
}
function applySelectedAddress(item: UserAddressItem) {
store.setReturnAddress({
id: item.id,
@@ -125,11 +145,70 @@ async function loadPreview() {
showInfoToast("草稿已自动恢复,请确认订单信息后继续提交。");
}
store.setPreview(data);
syncPackageFromPreview(data);
} catch (error) {
showErrorToast(error, "订单预览加载失败");
}
}
async function loadPackageOptions() {
if (packageOptionsLoading.value) return;
packageOptionsLoading.value = true;
packageOptionsError.value = "";
try {
const data = await appraisalApi.getServiceConfigs();
const provider = normalizeProvider(currentServiceProvider.value);
const config = data.list.find((item) => normalizeProvider(item.service_provider) === provider);
packageOptions.value = config?.packages || [];
} catch (error) {
packageOptions.value = [];
packageOptionsError.value = "套餐列表加载失败,当前订单仍可按已选套餐继续支付。";
showErrorToast(error, "套餐列表加载失败");
} finally {
packageOptionsLoading.value = false;
}
}
async function selectPricePackage(item: AppraisalServicePackage) {
if (packageUpdating.value || submitting.value || loading.value) return;
if (!item.is_enabled) {
showInfoToast("所选价格套餐已停用,请选择其他套餐");
return;
}
if (item.id === selectedPackageId.value) return;
if (!store.draftId) {
showInfoToast("订单信息未准备完成,请返回上一步检查");
return;
}
packageUpdating.value = true;
try {
await withLoading("正在更新套餐", async () => {
const serviceProvider = normalizeProvider(currentServiceProvider.value);
await appraisalApi.saveDraft({
draft_id: store.draftId,
current_step: store.currentStep || 4,
service_provider: serviceProvider,
price_package_id: item.id,
price_package_code: item.package_code,
});
store.setServiceProvider(serviceProvider);
store.setPricePackage({
id: item.id,
packageName: item.package_name,
packageCode: item.package_code,
price: item.price,
});
store.setPreview(null);
await loadPreview();
});
} catch (error) {
showErrorToast(error, "套餐更新失败");
} finally {
packageUpdating.value = false;
}
}
async function goSuccess() {
if (submitting.value) return;
if (!store.draftId) {
@@ -187,6 +266,7 @@ onLoad(async () => {
store.hydrate();
await fetchAddresses();
await loadPreview();
await loadPackageOptions();
});
onShow(fetchAddresses);
@@ -195,16 +275,8 @@ onShow(fetchAddresses);
<template>
<view class="confirm-page">
<view class="confirm-nav">
<view class="confirm-nav__home" @click="goBack">
<view class="confirm-nav__home-roof"></view>
<view class="confirm-nav__home-body"></view>
</view>
<view class="confirm-nav__back" @click="goBack"></view>
<view class="confirm-nav__title">确认订单</view>
<view class="confirm-nav__capsule">
<text class="confirm-nav__dots"></text>
<view class="confirm-nav__divider"></view>
<view class="confirm-nav__circle"></view>
</view>
</view>
<view v-if="loading" class="confirm-state">
@@ -234,16 +306,51 @@ onShow(fetchAddresses);
<view class="product-summary__thumb">
<view class="product-summary__mark">{{ brandText === "未填写" ? "AXY" : brandText.slice(0, 2) }}</view>
</view>
<view class="product-summary__info">
<view class="product-summary__row">服务{{ serviceProviderText }}</view>
<view v-if="packageNameText" class="product-summary__row">套餐{{ packageNameText }}</view>
<view class="product-summary__row">{{ categoryText }}</view>
<view class="product-summary__row">品牌{{ brandText }}</view>
<view class="product-summary__info">
<view class="product-summary__row">服务{{ serviceProviderText }}</view>
<view class="product-summary__row">品类{{ categoryText }}</view>
<view class="product-summary__row">{{ brandText }}</view>
</view>
</view>
<view class="confirm-card__muted confirm-card__muted--top">{{ productNameText }}</view>
</view>
<view class="confirm-card package-section">
<view class="package-section__head">
<view>
<view class="confirm-card__title">价格套餐</view>
<view class="package-section__desc">选择本次鉴定服务使用的价格套餐</view>
</view>
<view v-if="packageUpdating" class="package-section__status">更新中...</view>
</view>
<view v-if="packageOptionsLoading" class="package-empty">套餐加载中...</view>
<view v-else-if="packageOptionsError" class="package-empty">{{ packageOptionsError }}</view>
<view v-else-if="packageOptions.length" class="package-list">
<view
v-for="item in packageOptions"
:key="item.id"
:class="[
'package-card',
selectedPackageId === item.id ? 'package-card--selected' : '',
packageUpdating ? 'package-card--disabled' : '',
]"
@click="selectPricePackage(item)"
>
<view class="package-card__main">
<view class="package-card__name">{{ item.package_name }}</view>
<view v-if="item.description" class="package-card__desc">{{ item.description }}</view>
</view>
<view class="package-card__side">
<view class="package-card__price">{{ formatMoney(item.price) }}</view>
<view v-if="selectedPackageId === item.id" class="package-card__tag">已选</view>
<view v-else-if="item.is_default" class="package-card__tag package-card__tag--muted">默认</view>
</view>
</view>
</view>
<view v-else class="package-empty">当前服务暂无可切换套餐</view>
</view>
<view class="confirm-card">
<view class="fee-head">
<view class="confirm-card__title">费用明细</view>
@@ -340,51 +447,34 @@ onShow(fetchAddresses);
}
.confirm-nav {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
min-height: 72rpx;
margin-bottom: 28rpx;
}
.confirm-nav__home {
position: relative;
width: 52rpx;
height: 52rpx;
}
.confirm-nav__home-roof {
.confirm-nav__back {
position: absolute;
left: 10rpx;
top: 4rpx;
width: 32rpx;
height: 32rpx;
border-left: 5rpx solid #252527;
border-top: 5rpx solid #252527;
transform: rotate(45deg);
left: 0;
top: 50%;
width: 64rpx;
height: 64rpx;
transform: translateY(-50%);
}
.confirm-nav__home-body {
position: absolute;
left: 12rpx;
bottom: 6rpx;
width: 30rpx;
height: 28rpx;
border: 5rpx solid #252527;
border-top: 0;
border-radius: 4rpx;
background: #ffffff;
}
.confirm-nav__home-body::after {
.confirm-nav__back::before {
content: "";
position: absolute;
right: 2rpx;
bottom: 2rpx;
width: 18rpx;
height: 12rpx;
border-radius: 12rpx 12rpx 12rpx 4rpx;
background: #edbd00;
left: 20rpx;
top: 17rpx;
width: 24rpx;
height: 24rpx;
border-left: 5rpx solid #252527;
border-bottom: 5rpx solid #252527;
border-radius: 2rpx;
transform: rotate(45deg);
}
.confirm-nav__title {
@@ -394,38 +484,6 @@ onShow(fetchAddresses);
line-height: 1.2;
}
.confirm-nav__capsule {
display: flex;
align-items: center;
justify-content: center;
width: 150rpx;
height: 64rpx;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
}
.confirm-nav__dots {
color: #111111;
font-size: 36rpx;
font-weight: 800;
line-height: 1;
}
.confirm-nav__divider {
width: 1px;
height: 36rpx;
margin: 0 18rpx;
background: rgba(0, 0, 0, 0.3);
}
.confirm-nav__circle {
width: 34rpx;
height: 34rpx;
border: 7rpx solid #111111;
border-radius: 50%;
}
.confirm-state,
.confirm-card,
.agreement-item {
@@ -595,6 +653,116 @@ onShow(fetchAddresses);
word-break: break-all;
}
.package-section__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.package-section__desc {
margin-top: 6rpx;
color: #a0a0a4;
font-size: 24rpx;
line-height: 1.5;
}
.package-section__status {
flex-shrink: 0;
color: #a0a0a4;
font-size: 24rpx;
line-height: 1.5;
}
.package-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx;
}
.package-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
min-height: 128rpx;
padding: 22rpx 24rpx;
border: 2rpx solid #e6e6e8;
border-radius: 16rpx;
background: #ffffff;
}
.package-card--selected {
border-color: #edbd00;
background: #fffaf0;
}
.package-card--disabled {
opacity: 0.68;
}
.package-card__main {
flex: 1;
min-width: 0;
}
.package-card__name {
color: #252527;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.package-card__desc {
margin-top: 8rpx;
color: #8c8c90;
font-size: 22rpx;
line-height: 1.45;
}
.package-card__side {
flex-shrink: 0;
text-align: right;
}
.package-card__price {
color: #edbd00;
font-size: 34rpx;
font-weight: 900;
line-height: 1.2;
}
.package-card__tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64rpx;
height: 34rpx;
margin-top: 8rpx;
border-radius: 999rpx;
background: rgba(237, 189, 0, 0.13);
color: #9a7700;
font-size: 20rpx;
font-weight: 700;
}
.package-card__tag--muted {
background: #f0f0f2;
color: #8c8c90;
}
.package-empty {
margin-top: 20rpx;
padding: 30rpx 24rpx;
border-radius: 16rpx;
background: #f7f7f8;
color: #9a9a9d;
font-size: 24rpx;
line-height: 1.5;
text-align: center;
}
.fee-head {
display: flex;
align-items: center;

View File

@@ -175,7 +175,7 @@ async function saveProductAndGoConfirm(payload: { brandId: number; brandName: st
function selectBrand(item: BrandOption) {
void saveProductAndGoConfirm({
brandId: item.id,
brandName: item.name || item.enName || item.displayName,
brandName: item.displayName,
});
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { appraisalApi, type AppraisalServiceConfig, type AppraisalServicePackage, type CategoryOption } from "../../api/appraisal";
import { appraisalApi, type CategoryOption } from "../../api/appraisal";
import { useAppraisalStore } from "../../stores/appraisal";
import { isLoggedIn, redirectToLogin } from "../../utils/auth";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
@@ -12,6 +12,7 @@ type CategoryPickerItem = {
id: number;
name: string;
code: string;
imageUrl: string;
visual: string;
};
@@ -19,7 +20,6 @@ const providerIntro: Record<ServiceProvider, {
navTitle: string;
logoText: string;
intro: string;
priceText: string;
highlights: string[];
steps: Array<{
title: string;
@@ -31,7 +31,6 @@ const providerIntro: Record<ServiceProvider, {
navTitle: "安心验鉴定",
logoText: "安心验 鉴定",
intro: "安心验(深圳)商品检验鉴定有限责任公司立足深圳核心产业服务区,是一家专业从事商品检验、鉴定、测试及技术咨询的第三方服务机构。公司依托粤港澳大湾区雄厚的产业基础与国际贸易枢纽优势,致力于为 C 端消费者及 B 端电商平台、商家提供网购商品真伪鉴定、成色评级、价值评估及争议仲裁等一站式解决方案。",
priceText: "¥99 起",
highlights: ["独立第三方", "报告可验真", "流程可追踪"],
steps: [
{ title: "下单付款", desc: "商品寄至鉴定中心,录像验收。", visual: "pay" },
@@ -44,7 +43,6 @@ const providerIntro: Record<ServiceProvider, {
navTitle: "中检鉴定",
logoText: "中检 鉴定",
intro: "中检鉴定服务面向更高规格出具需求,沿用安心验标准化下单、寄送与进度追踪流程,由合作机构完成对应服务交付,适用于对报告出具方有明确要求的鉴定场景。",
priceText: "¥199 起",
highlights: ["合作机构", "报告出具方不同", "流程一致"],
steps: [
{ title: "选择中检服务", desc: "首页选定中检鉴定后,确认品类、品牌和费用。", visual: "pay" },
@@ -75,24 +73,8 @@ const categorySheetVisible = ref(false);
const submitting = ref(false);
const loadError = ref("");
const selectedCategoryId = ref(0);
const selectedPackageId = ref(0);
const serviceConfigsLoading = ref(false);
const serviceConfigs = ref<Record<ServiceProvider, AppraisalServiceConfig | undefined>>({
anxinyan: undefined,
zhongjian: undefined,
});
const currentIntro = computed(() => providerIntro[providerCode.value]);
const currentPackages = computed<AppraisalServicePackage[]>(() => serviceConfigs.value[providerCode.value]?.packages || []);
const currentPackage = computed(() => currentPackages.value.find((item) => item.id === selectedPackageId.value) || currentPackages.value.find((item) => item.is_default) || currentPackages.value[0]);
const currentPriceText = computed(() => {
if (currentPackage.value) {
return `¥${formatPrice(Number(currentPackage.value.price))}`;
}
const price = serviceConfigs.value[providerCode.value]?.price;
return Number(price || 0) > 0 ? `¥${formatPrice(Number(price))}` : currentIntro.value.priceText;
});
const currentPriceDesc = computed(() => currentPackage.value?.package_name || "请选择价格套餐");
const providerThemeClass = computed(() => `service-intro--${providerCode.value}`);
function normalizeProvider(value?: string): ServiceProvider {
@@ -116,44 +98,6 @@ function resolveCategoryVisual(item: CategoryOption) {
return matched?.visual || "default";
}
function formatPrice(price: number) {
return Number.isInteger(price) ? String(price) : price.toFixed(2);
}
async function loadServiceConfigs() {
if (serviceConfigsLoading.value) return;
serviceConfigsLoading.value = true;
try {
const data = await appraisalApi.getServiceConfigs();
const nextConfigs: Record<ServiceProvider, AppraisalServiceConfig | undefined> = {
anxinyan: undefined,
zhongjian: undefined,
};
data.list.forEach((item) => {
const provider = normalizeProvider(item.service_provider);
nextConfigs[provider] = item;
});
serviceConfigs.value = nextConfigs;
applyDefaultPackage();
} catch (error) {
console.warn("service config fallback", error);
} finally {
serviceConfigsLoading.value = false;
}
}
function applyDefaultPackage() {
const packages = currentPackages.value;
if (packages.some((item) => item.id === selectedPackageId.value)) return;
const target = packages.find((item) => item.is_default) || packages[0];
selectedPackageId.value = target?.id || 0;
}
function selectPackage(item: AppraisalServicePackage) {
if (!item.is_enabled) return;
selectedPackageId.value = item.id;
}
function buildServicePageUrl(options: ServicePageOptions = {}) {
const params: string[] = [];
const provider = options.provider || providerCode.value;
@@ -189,6 +133,7 @@ async function loadCategories() {
id: item.category_id,
name: item.category_name,
code: item.category_code,
imageUrl: item.image_url || "",
visual: resolveCategoryVisual(item),
}));
categoriesLoaded.value = true;
@@ -209,14 +154,6 @@ async function openCategorySheet() {
return;
}
if (!currentPackage.value) {
await loadServiceConfigs();
}
if (!currentPackage.value) {
showInfoToast("当前服务暂无可用价格套餐");
return;
}
categorySheetVisible.value = true;
await loadCategories();
}
@@ -228,11 +165,6 @@ function closeCategorySheet() {
async function selectCategory(item: CategoryPickerItem) {
if (submitting.value) return;
const selectedPackage = currentPackage.value;
if (!selectedPackage) {
showInfoToast("请先选择价格套餐");
return;
}
selectedCategoryId.value = item.id;
submitting.value = true;
try {
@@ -241,13 +173,7 @@ async function selectCategory(item: CategoryPickerItem) {
store.resetForNewFlow();
store.clearLegacyExtraDefaults();
store.setServiceProvider(providerCode.value);
store.setPricePackage({
id: selectedPackage.id,
packageName: selectedPackage.package_name,
packageCode: selectedPackage.package_code,
price: selectedPackage.price,
});
const draft = await appraisalApi.createDraft(providerCode.value, selectedPackage.id, selectedPackage.package_code);
const draft = await appraisalApi.createDraft(providerCode.value);
draftId = draft.draft_id;
store.setDraft(draftId);
store.setPricePackage({
@@ -293,21 +219,18 @@ function categoryKey(item: CategoryPickerItem) {
return `${item.id}-${item.name}`;
}
function goBack() {
uni.navigateBack();
function goHome() {
uni.switchTab({ url: "/pages/home/index" });
}
function applyProviderFromOptions(options: ServicePageOptions = {}) {
providerCode.value = normalizeProvider(options.provider);
selectedPackageId.value = 0;
applyDefaultPackage();
uni.setNavigationBarTitle({ title: currentIntro.value.navTitle });
}
onLoad((options: ServicePageOptions = {}) => {
const resolvedOptions = resolveServicePageOptions(options);
applyProviderFromOptions(resolvedOptions);
void loadServiceConfigs();
if (resolvedOptions.start === "1" && isLoggedIn()) {
setTimeout(() => {
void openCategorySheet();
@@ -322,16 +245,11 @@ onLoad((options: ServicePageOptions = {}) => {
<view :class="['service-intro', providerThemeClass]">
<view class="service-intro__hero">
<view class="service-intro__nav">
<view class="service-intro__home" @click="goBack">
<view class="service-intro__home" @click="goHome">
<view class="service-intro__home-roof"></view>
<view class="service-intro__home-body"></view>
</view>
<view class="service-intro__title">鉴定服务</view>
<view class="service-intro__capsule">
<text class="service-intro__dots"></text>
<view class="service-intro__divider"></view>
<view class="service-intro__circle"></view>
</view>
</view>
<view class="service-intro__brand">
@@ -363,32 +281,9 @@ onLoad((options: ServicePageOptions = {}) => {
</view>
</view>
<view class="service-intro__package-title">价格套餐</view>
<view v-if="currentPackages.length" class="package-list">
<view
v-for="item in currentPackages"
:key="item.id"
:class="['package-card', selectedPackageId === item.id ? 'package-card--selected' : '']"
@click="selectPackage(item)"
>
<view class="package-card__main">
<view class="package-card__name">{{ item.package_name }}</view>
<view v-if="item.description" class="package-card__desc">{{ item.description }}</view>
</view>
<view class="package-card__side">
<view class="package-card__price">¥{{ formatPrice(item.price) }}</view>
<view v-if="item.is_default" class="package-card__tag">默认</view>
</view>
</view>
</view>
<view v-else class="package-empty">{{ serviceConfigsLoading ? "套餐加载中..." : "当前服务暂无可用套餐" }}</view>
</view>
<view class="service-intro__action-bar">
<view>
<view class="service-intro__price">{{ currentPriceText }}</view>
<view class="service-intro__price-desc">{{ currentPriceDesc }}</view>
</view>
<view :class="['service-intro__primary', submitting ? 'service-intro__primary--disabled' : '']" @click="openCategorySheet">
{{ submitting ? "处理中..." : "发起鉴定" }}
</view>
@@ -412,7 +307,13 @@ onLoad((options: ServicePageOptions = {}) => {
@click="selectCategory(item)"
>
<view class="category-card__name">{{ item.name }}</view>
<view :class="['category-card__visual', `category-card__visual--${item.visual}`]"></view>
<image
v-if="item.imageUrl"
class="category-card__image"
:src="item.imageUrl"
mode="aspectFit"
/>
<view v-else :class="['category-card__visual', `category-card__visual--${item.visual}`]"></view>
</view>
</view>
</view>
@@ -495,22 +396,25 @@ onLoad((options: ServicePageOptions = {}) => {
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
min-height: 64rpx;
}
.service-intro__home {
position: relative;
width: 52rpx;
height: 52rpx;
position: absolute;
left: 0;
top: 50%;
width: 64rpx;
height: 64rpx;
transform: translateY(-50%);
}
.service-intro__home-roof {
position: absolute;
left: 10rpx;
top: 4rpx;
width: 32rpx;
height: 32rpx;
left: 15rpx;
top: 9rpx;
width: 34rpx;
height: 34rpx;
border-left: 5rpx solid #252527;
border-top: 5rpx solid #252527;
transform: rotate(45deg);
@@ -518,10 +422,10 @@ onLoad((options: ServicePageOptions = {}) => {
.service-intro__home-body {
position: absolute;
left: 12rpx;
bottom: 6rpx;
width: 30rpx;
height: 28rpx;
left: 17rpx;
bottom: 10rpx;
width: 31rpx;
height: 29rpx;
border: 5rpx solid #252527;
border-top: 0;
border-radius: 4rpx;
@@ -546,38 +450,6 @@ onLoad((options: ServicePageOptions = {}) => {
line-height: 1.2;
}
.service-intro__capsule {
display: flex;
align-items: center;
justify-content: center;
width: 146rpx;
height: 62rpx;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
}
.service-intro__dots {
color: #111111;
font-size: 36rpx;
font-weight: 800;
line-height: 1;
}
.service-intro__divider {
width: 1px;
height: 36rpx;
margin: 0 18rpx;
background: rgba(0, 0, 0, 0.3);
}
.service-intro__circle {
width: 34rpx;
height: 34rpx;
border: 7rpx solid #111111;
border-radius: 50%;
}
.service-intro__brand {
position: relative;
z-index: 1;
@@ -839,109 +711,6 @@ onLoad((options: ServicePageOptions = {}) => {
width: 232rpx;
}
.service-intro__package-title {
margin-top: 6rpx;
color: #252527;
font-size: 32rpx;
font-weight: 800;
line-height: 1.3;
text-align: center;
}
.package-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx;
}
.package-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
min-height: 128rpx;
padding: 22rpx 24rpx;
border: 2rpx solid #e6e6e8;
border-radius: 16rpx;
background: #ffffff;
}
.package-card--selected {
border-color: #edbd00;
background: #fffaf0;
}
.service-intro--zhongjian .package-card--selected {
border-color: #416f9e;
background: #f3f7fb;
}
.package-card__main {
flex: 1;
min-width: 0;
}
.package-card__name {
color: #252527;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.package-card__desc {
margin-top: 8rpx;
color: #8c8c90;
font-size: 22rpx;
line-height: 1.45;
}
.package-card__side {
flex-shrink: 0;
text-align: right;
}
.package-card__price {
color: #edbd00;
font-size: 34rpx;
font-weight: 900;
line-height: 1.2;
}
.service-intro--zhongjian .package-card__price {
color: #416f9e;
}
.package-card__tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64rpx;
height: 34rpx;
margin-top: 8rpx;
border-radius: 999rpx;
background: rgba(237, 189, 0, 0.13);
color: #9a7700;
font-size: 20rpx;
font-weight: 700;
}
.service-intro--zhongjian .package-card__tag {
background: rgba(65, 111, 158, 0.12);
color: #416f9e;
}
.package-empty {
margin-top: 20rpx;
padding: 30rpx 24rpx;
border-radius: 16rpx;
background: #ffffff;
color: #9a9a9d;
font-size: 24rpx;
line-height: 1.5;
text-align: center;
}
.service-intro__action-bar {
position: fixed;
left: 0;
@@ -950,33 +719,18 @@ onLoad((options: ServicePageOptions = {}) => {
z-index: 90;
display: flex;
align-items: center;
justify-content: space-between;
gap: 28rpx;
justify-content: center;
padding: 22rpx 32rpx calc(22rpx + env(safe-area-inset-bottom));
border-top: 1px solid #e9e9eb;
background: rgba(255, 255, 255, 0.96);
}
.service-intro__price {
color: #edbd00;
font-size: 40rpx;
font-weight: 800;
line-height: 1.2;
}
.service-intro__price-desc {
margin-top: 4rpx;
color: #9a9a9d;
font-size: 22rpx;
line-height: 1.4;
}
.service-intro__primary {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
max-width: 360rpx;
max-width: 640rpx;
height: 86rpx;
border-radius: 999rpx;
background: #edbd00;
@@ -1095,6 +849,14 @@ onLoad((options: ServicePageOptions = {}) => {
background: #f7f7f8;
}
.category-card__image {
position: absolute;
right: 18rpx;
bottom: 18rpx;
width: 128rpx;
height: 104rpx;
}
.category-card__visual::before,
.category-card__visual::after {
content: "";

View File

@@ -12,7 +12,7 @@ const pageLoading = ref(false);
const pageReady = ref(false);
const categoryDataLoaded = ref(false);
const loadError = ref("");
const defaultHeroBackground = "/static/home/home-reference.jpg";
const defaultHeroBackground = "/static/home/home-hero-bg.png";
const categoryFallbackVisuals = [
{ visual: "bag", keys: ["luxury_bag", "奢侈品箱包", "箱包"] },
@@ -239,7 +239,7 @@ onShow(fetchHome);
height: 470rpx;
overflow: hidden;
background-size: cover;
background-position: center top;
background-position: center 44%;
background-repeat: no-repeat;
}

View File

@@ -209,6 +209,10 @@ const secondaryActionText = computed(() =>
isPendingPayment.value ? (cancelSubmitting.value ? "取消中..." : "取消订单") : detail.value.available_actions.secondary_action,
);
function goOrderList() {
uni.switchTab({ url: "/pages/order/index" });
}
async function fetchDetail() {
if (!orderId.value) return;
loading.value = true;
@@ -424,6 +428,11 @@ onShow(async () => {
<template>
<view class="app-page app-page--tight">
<view class="order-detail-nav">
<view class="order-detail-nav__back" @click="goOrderList"></view>
<view class="order-detail-nav__title">订单详情</view>
</view>
<view v-if="!pageReady && loading" class="section notice-card">
<view class="notice-card__title">正在加载订单详情</view>
<view class="notice-card__desc">请稍候我们正在同步订单状态资料寄回地址和处理记录</view>
@@ -710,6 +719,44 @@ onShow(async () => {
</template>
<style scoped>
.order-detail-nav {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 72rpx;
margin-bottom: 24rpx;
}
.order-detail-nav__back {
position: absolute;
left: 0;
top: 50%;
width: 64rpx;
height: 64rpx;
transform: translateY(-50%);
}
.order-detail-nav__back::before {
content: "";
position: absolute;
left: 20rpx;
top: 17rpx;
width: 24rpx;
height: 24rpx;
border-left: 5rpx solid #252527;
border-bottom: 5rpx solid #252527;
border-radius: 2rpx;
transform: rotate(45deg);
}
.order-detail-nav__title {
color: var(--color-heading);
font-size: 38rpx;
font-weight: var(--font-weight-semibold);
line-height: 1.2;
}
.order-detail-hero {
padding-bottom: 34rpx;
}

View File

@@ -202,7 +202,7 @@ onShow(async () => {
{{ item.service_provider === "zhongjian" ? "中检鉴定" : "安心验鉴定" }}
<text v-if="item.price_package_name"> / {{ item.price_package_name }}</text>
</view>
<view class="order-card__action">{{ item.primary_action }}</view>
<view v-if="item.primary_action" class="order-card__action">{{ item.primary_action }}</view>
</view>
</view>
</view>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -199,6 +199,12 @@ export interface AdminManualOrderMeta {
}>;
}
export interface AdminCatalogCategoryOption {
id: number;
name: string;
code: string;
}
export interface AdminOrderDetail {
order_info: AdminOrderListItem & {
can_mark_received: boolean;
@@ -541,6 +547,25 @@ export const adminApi = {
getManualOrderMeta() {
return request<AdminManualOrderMeta>("/api/admin/manual-order/meta");
},
async getAppCatalogCategories() {
const data = await request<{
category_entries: Array<{
category_id: number;
category_name: string;
category_code: string;
}>;
}>("/api/app/home/index");
const list: AdminCatalogCategoryOption[] = data.category_entries.map((item) => ({
id: item.category_id,
name: item.category_name,
code: item.category_code,
}));
return {
list,
};
},
getExpressCompanies(params: { enabled_only?: 0 | 1 } = { enabled_only: 1 }) {
return request<{ list: AdminExpressCompanyItem[]; default_company: string }>("/api/admin/express-companies", { params });
},

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { adminApi, type AdminAppraisalTaskDetail, type AdminFileAsset } from "../../api/admin";
import { adminApi, type AdminAppraisalTaskDetail, type AdminCatalogCategoryOption, type AdminFileAsset } from "../../api/admin";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
const loading = ref(false);
@@ -25,6 +25,7 @@ const externalRemark = ref("");
const internalRemark = ref("");
const zhongjianReportNo = ref("");
const productName = ref("");
const categoryId = ref(0);
const categoryName = ref("");
const brandName = ref("");
const color = ref("");
@@ -33,6 +34,8 @@ const serialNo = ref("");
const zhongjianFiles = ref<AdminFileAsset[]>([]);
const evidenceFiles = ref<AdminFileAsset[]>([]);
const activePreviewVideo = ref<AdminFileAsset | null>(null);
const catalogCategories = ref<AdminCatalogCategoryOption[]>([]);
const categoryLoading = ref(false);
const supplementForm = reactive({
reason: "",
deadline: "",
@@ -56,6 +59,13 @@ const internalTagNo = computed(() => detail.value?.task_info.internal_tag_no ||
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
const reportSummary = computed(() => detail.value?.report_summary?.report_no || "");
const hasBoundMaterialTag = computed(() => Boolean(detail.value?.material_tag?.id));
const categoryOptions = computed(() => catalogCategories.value);
const categoryPickerIndex = computed(() => Math.max(0, categoryOptions.value.findIndex((item) => item.id === categoryId.value)));
const categoryPickerLabel = computed(() => selectedCategoryName(categoryId.value) || categoryName.value.trim());
const categoryPickerPlaceholder = computed(() => {
if (categoryLoading.value) return "正在加载品类";
return categoryOptions.value.length ? "请选择品类" : "暂无可选品类";
});
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
function hasConditionFields(template?: AppraisalTemplate | null) {
@@ -74,6 +84,58 @@ function formatMoneyInput(value: string | number) {
return Number.isFinite(num) ? num : 0;
}
function selectedCategoryName(selectedCategoryId: number) {
return categoryOptions.value.find((item) => item.id === selectedCategoryId)?.name || "";
}
function resolveCategoryId(selectedCategoryId: number, selectedCategoryNameText: string) {
if (selectedCategoryId) return selectedCategoryId;
const categoryNameText = selectedCategoryNameText.trim();
return categoryOptions.value.find((item) => item.name === categoryNameText)?.id || 0;
}
function syncCurrentCategory() {
const resolvedCategoryId = resolveCategoryId(categoryId.value, categoryName.value);
categoryId.value = resolvedCategoryId;
categoryName.value = selectedCategoryName(resolvedCategoryId) || categoryName.value;
}
async function fetchCatalogMeta() {
if (catalogCategories.value.length || categoryLoading.value) return;
categoryLoading.value = true;
try {
const data = await adminApi.getAppCatalogCategories();
catalogCategories.value = data.list;
syncCurrentCategory();
} catch (error) {
showErrorToast(error, "品类列表加载失败");
} finally {
categoryLoading.value = false;
}
}
function onCategoryChange(event: any) {
if (isTaskReadonly.value) return;
const index = Number(event.detail?.value || 0);
const category = categoryOptions.value[index];
if (!category) return;
categoryId.value = category.id;
categoryName.value = category.name;
}
function productInfoPayload() {
const resolvedCategoryId = resolveCategoryId(categoryId.value, categoryName.value);
return {
category_id: resolvedCategoryId,
product_name: productName.value.trim(),
category_name: selectedCategoryName(resolvedCategoryId) || categoryName.value.trim(),
brand_name: brandName.value.trim(),
color: color.value.trim(),
size_spec: sizeSpec.value.trim(),
serial_no: serialNo.value.trim(),
};
}
function hydrate(detailData: AdminAppraisalTaskDetail) {
detail.value = detailData;
activeSection.value = detailData.task_info.service_provider === "zhongjian"
@@ -102,7 +164,9 @@ function hydrate(detailData: AdminAppraisalTaskDetail) {
internalRemark.value = detailData.result_info.internal_remark || "";
zhongjianReportNo.value = detailData.zhongjian_report?.report_no || "";
productName.value = detailData.product_info.product_name || "";
categoryId.value = resolveCategoryId(detailData.product_info.category_id, detailData.product_info.category_name || "");
categoryName.value = detailData.product_info.category_name || "";
syncCurrentCategory();
brandName.value = detailData.product_info.brand_name || "";
color.value = detailData.product_info.color || "";
sizeSpec.value = detailData.product_info.size_spec || "";
@@ -556,15 +620,7 @@ async function submitResult(action: "save" | "submit") {
adminApi.saveAppraisalTaskResult({
id: detail.value!.task_info.id,
action,
product_info: {
category_id: detail.value!.product_info.category_id,
product_name: productName.value.trim(),
category_name: categoryName.value.trim(),
brand_name: brandName.value.trim(),
color: color.value.trim(),
size_spec: sizeSpec.value.trim(),
serial_no: serialNo.value.trim(),
},
product_info: productInfoPayload(),
result_text: resultText.value.trim(),
result_desc: resultDesc.value.trim(),
...conditionPayload,
@@ -662,15 +718,7 @@ async function submitZhongjianReport() {
await adminApi.saveZhongjianAppraisalReport({
id: detail.value.task_info.id,
zhongjian_report_no: zhongjianReportNo.value.trim(),
product_info: {
category_id: detail.value.product_info.category_id,
product_name: productName.value.trim(),
category_name: categoryName.value.trim(),
brand_name: brandName.value.trim(),
color: color.value.trim(),
size_spec: sizeSpec.value.trim(),
serial_no: serialNo.value.trim(),
},
product_info: productInfoPayload(),
result_text: resultText.value.trim(),
result_desc: resultDesc.value.trim(),
attachments: evidenceFiles.value,
@@ -700,8 +748,11 @@ onLoad((options) => {
});
onShow(() => {
if (taskId.value && !pageReady.value) {
void fetchDetail();
if (taskId.value) {
void fetchCatalogMeta();
if (!pageReady.value) {
void fetchDetail();
}
}
});
</script>
@@ -767,7 +818,19 @@ onShow(() => {
<view class="stack" style="margin-top: 18rpx">
<view class="card-desc">报告展示信息</view>
<input v-model="productName" class="field" :disabled="isTaskReadonly" placeholder="产品名称" />
<input v-model="categoryName" class="field" :disabled="isTaskReadonly" placeholder="品类" />
<picker
:range="categoryOptions"
range-key="name"
:value="categoryPickerIndex"
:disabled="isTaskReadonly || categoryLoading || !categoryOptions.length"
@change="onCategoryChange"
>
<view class="field picker-field" :class="{ 'picker-field--disabled': isTaskReadonly }">
<text v-if="categoryPickerLabel" class="picker-field__value">{{ categoryPickerLabel }}</text>
<text v-else class="picker-field__placeholder">{{ categoryPickerPlaceholder }}</text>
<text v-if="!isTaskReadonly" class="picker-field__arrow"></text>
</view>
</picker>
<input v-model="brandName" class="field" :disabled="isTaskReadonly" placeholder="品牌" />
<view class="meta-grid">
<input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" />
@@ -876,7 +939,19 @@ onShow(() => {
<view class="stack" style="margin-top: 18rpx">
<view class="card-desc">报告展示信息</view>
<input v-model="productName" class="field" :disabled="isTaskReadonly" placeholder="产品名称" />
<input v-model="categoryName" class="field" :disabled="isTaskReadonly" placeholder="品类" />
<picker
:range="categoryOptions"
range-key="name"
:value="categoryPickerIndex"
:disabled="isTaskReadonly || categoryLoading || !categoryOptions.length"
@change="onCategoryChange"
>
<view class="field picker-field" :class="{ 'picker-field--disabled': isTaskReadonly }">
<text v-if="categoryPickerLabel" class="picker-field__value">{{ categoryPickerLabel }}</text>
<text v-else class="picker-field__placeholder">{{ categoryPickerPlaceholder }}</text>
<text v-if="!isTaskReadonly" class="picker-field__arrow"></text>
</view>
</picker>
<input v-model="brandName" class="field" :disabled="isTaskReadonly" placeholder="品牌" />
<view class="meta-grid">
<input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" />
@@ -1051,6 +1126,42 @@ onShow(() => {
font-weight: 800;
}
.picker-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.picker-field--disabled {
opacity: 0.82;
}
.picker-field__value,
.picker-field__placeholder {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-field__value {
color: var(--work-text);
}
.picker-field__placeholder {
color: var(--work-text-muted);
}
.picker-field__arrow {
width: 14rpx;
height: 14rpx;
flex: 0 0 14rpx;
border-right: 3rpx solid var(--work-text-soft);
border-bottom: 3rpx solid var(--work-text-soft);
transform: rotate(45deg);
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));