Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169c36734a | ||
|
|
68c25790fd | ||
|
|
c0894cbab1 | ||
|
|
8a0f5b6775 | ||
|
|
f7d144e440 | ||
|
|
6187f35a62 | ||
|
|
0edb2c5565 | ||
|
|
649144897b | ||
|
|
6674a6690b | ||
|
|
a87609261b | ||
|
|
844879773f | ||
|
|
34fed4702d | ||
|
|
9332f6ddb8 | ||
|
|
b841f202a7 | ||
|
|
822767ac64 | ||
|
|
f320717f84 | ||
|
|
6cce293683 | ||
|
|
8d9d0c4591 | ||
|
|
f191729b58 | ||
|
|
2080a88eb8 | ||
|
|
45818de9f1 | ||
|
|
584fbfadf5 | ||
|
|
194a55a03f | ||
|
|
cb4c19f9e4 | ||
|
|
b3c01545ab | ||
|
|
f5b246a50e |
@@ -6,6 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "pnpm run type-check:publish && vite build",
|
||||
"build:zip": "pnpm run build && powershell -NoProfile -Command \"New-Item -ItemType Directory -Force -Path release | Out-Null; Compress-Archive -Path dist\\* -DestinationPath release\\gao-customer-h5.zip -Force\"",
|
||||
"twa:build": "cd twa && pnpm --config.registry=https://registry.npmjs.org dlx @bubblewrap/cli@1.25.0 build --manifest=./twa-manifest.json",
|
||||
"twa:update": "cd twa && pnpm --config.registry=https://registry.npmjs.org dlx @bubblewrap/cli@1.25.0 update --skipVersionUpgrade --manifest=./twa-manifest.json",
|
||||
"twa:validate": "pnpm --config.registry=https://registry.npmjs.org dlx @bubblewrap/cli@1.25.0 validate --url=https://gao.imyeyu.com/",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .ts,.vue",
|
||||
"type-check": "vue-tsc -p tsconfig.app.json --noEmit",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.imyeyu.gao.customer",
|
||||
"sha256_cert_fingerprints": [
|
||||
"EF:65:BA:62:7D:E3:1C:D3:BD:C7:D9:1D:6A:69:35:81:56:87:DF:03:0D:E7:76:27:94:90:5E:CC:F4:45:02:69"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "Gao Customer H5",
|
||||
"short_name": "Gao H5",
|
||||
"id": "/",
|
||||
"name": "糕店客户管理",
|
||||
"short_name": "糕店管理",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
|
||||
@@ -7,11 +7,20 @@ import type {
|
||||
PointLedger,
|
||||
PointLedgerPage,
|
||||
PointRule,
|
||||
GroovyScript
|
||||
GroovyScript,
|
||||
PointRedeemSku,
|
||||
PointRedeemSkuPage,
|
||||
PointRedemption,
|
||||
PointRedemptionCreateRequest,
|
||||
PointRedemptionPage,
|
||||
PointRedemptionReturn,
|
||||
PointRedemptionReturnRequest
|
||||
} from "@/types/Point";
|
||||
import { getCurrentStoreId, withCurrentStoreId, withCurrentStorePage } from "@/utils/store";
|
||||
|
||||
const BASE_URI = "/gao/customer/point";
|
||||
const REDEEM_SKU_URI = "/gao/point/redeem-sku";
|
||||
const REDEMPTION_URI = "/gao/point/redemption";
|
||||
|
||||
type ListOptions = {
|
||||
allStores?: boolean;
|
||||
@@ -118,6 +127,50 @@ async function ruleRemove(id: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function redeemSkuList(page: PointRedeemSkuPage): Promise<PageResult<PointRedeemSku>> {
|
||||
return await axios.post(`${REDEEM_SKU_URI}/list`, { ...page, storeId: page.storeId || getCurrentStoreId() });
|
||||
}
|
||||
|
||||
async function redeemSkuDetail(id: string, storeId?: string): Promise<PointRedeemSku> {
|
||||
return await axios.post(`${REDEEM_SKU_URI}/detail`, undefined, { params: { id, storeId: storeId || getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
async function redeemSkuCreate(item: PointRedeemSku): Promise<PointRedeemSku> {
|
||||
return await axios.post(`${REDEEM_SKU_URI}/create`, withCurrentStoreId(item));
|
||||
}
|
||||
|
||||
async function redeemSkuUpdate(item: PointRedeemSku): Promise<PointRedeemSku> {
|
||||
return await axios.post(`${REDEEM_SKU_URI}/update`, item);
|
||||
}
|
||||
|
||||
async function redeemSkuDisable(id: string): Promise<void> {
|
||||
await axios.post(`${REDEEM_SKU_URI}/disable`, undefined, { params: { id, storeId: getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
async function redeemSkuRemove(id: string): Promise<void> {
|
||||
await axios.post(`${REDEEM_SKU_URI}/delete`, undefined, { params: { id, storeId: getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
async function redemptionList(page: PointRedemptionPage): Promise<PageResult<PointRedemption>> {
|
||||
return await axios.post(`${REDEMPTION_URI}/list`, { ...page, storeId: page.storeId || getCurrentStoreId() });
|
||||
}
|
||||
|
||||
async function redemptionDetail(id: string, storeId?: string): Promise<PointRedemption> {
|
||||
return await axios.post(`${REDEMPTION_URI}/detail`, undefined, { params: { id, storeId: storeId || getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
async function redemptionCreate(request: PointRedemptionCreateRequest): Promise<PointRedemption> {
|
||||
return await axios.post(`${REDEMPTION_URI}/create`, withCurrentStoreId(request));
|
||||
}
|
||||
|
||||
async function redemptionReturn(request: PointRedemptionReturnRequest): Promise<PointRedemption> {
|
||||
return await axios.post(`${REDEMPTION_URI}/return`, request);
|
||||
}
|
||||
|
||||
async function redemptionReturnList(redemptionId: string): Promise<PointRedemptionReturn[]> {
|
||||
return await axios.post(`${REDEMPTION_URI}/return/list`, undefined, { params: { redemptionId, storeId: getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
export default {
|
||||
list,
|
||||
detailByCustomerId,
|
||||
@@ -130,5 +183,16 @@ export default {
|
||||
ruleCreate,
|
||||
ruleUpdate,
|
||||
ruleSort,
|
||||
ruleRemove
|
||||
ruleRemove,
|
||||
redeemSkuList,
|
||||
redeemSkuDetail,
|
||||
redeemSkuCreate,
|
||||
redeemSkuUpdate,
|
||||
redeemSkuDisable,
|
||||
redeemSkuRemove,
|
||||
redemptionList,
|
||||
redemptionDetail,
|
||||
redemptionCreate,
|
||||
redemptionReturn,
|
||||
redemptionReturnList
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ html.theme-dark body {
|
||||
.t-dropdown-menu {
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.t-dropdown-menu__item) {
|
||||
@@ -119,7 +119,7 @@ html.theme-dark body {
|
||||
.avatar {
|
||||
flex: none;
|
||||
display: block;
|
||||
border-radius: .875rem;
|
||||
border-radius: .875rem !important;
|
||||
|
||||
&.fallback {
|
||||
color: #2563EB;
|
||||
@@ -143,6 +143,15 @@ html.theme-dark body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fix-cell-title-clip {
|
||||
|
||||
.t-cell__title {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.entrance-fade-up-enter-active {
|
||||
transition: opacity .4s ease, transform .4s ease;
|
||||
transition-delay: var(--entrance-delay, 0ms);
|
||||
|
||||
@@ -93,13 +93,6 @@ const photoURL = computed(() => customer.value ? getCustomerPhotoURL(customer.va
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.avatar .t-image__img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.customer-cell {
|
||||
.code {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty-block" v-text="emptyText" />
|
||||
<div v-else class="center-tips" v-text="emptyText" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,22 +115,6 @@ function handleRemove(customer: Customer): void {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #52607a;
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
background: #f8fbff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.avatar .t-image__img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
|
||||
@@ -292,7 +292,6 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
|
||||
}
|
||||
|
||||
.sku-card {
|
||||
margin-top: 1.25rem;
|
||||
padding: .75rem;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: .875rem;
|
||||
@@ -349,7 +348,7 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
|
||||
.collapse {
|
||||
margin-bottom: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-header {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { VCan, VRole } from "timi-web-api/utils/directives/Access";
|
||||
import type { ApiResponse } from "timi-web-api";
|
||||
import { Message } from "@/utils/message";
|
||||
import vEntranceTransition from "@/directives/entranceTransition";
|
||||
import timiWebStyle from "../../timi-web/src/assets/style/timi-web.less?inline";
|
||||
import tdesignCustomStyle from "../../timi-tdesign-mobile/src/assets/style/tdesign-custom.less?inline";
|
||||
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import "tdesign-mobile-vue/es/style/index.css";
|
||||
@@ -20,6 +22,17 @@ import "@/assets/styles/global.less";
|
||||
|
||||
export const pinia = createPinia();
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
const libraryOverrideStyleId = "library-override-style";
|
||||
|
||||
function mountLibraryOverrideStyle(): void {
|
||||
const oldStyle = document.getElementById(libraryOverrideStyleId);
|
||||
oldStyle?.remove();
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = libraryOverrideStyleId;
|
||||
style.textContent = `${timiWebStyle}\n${tdesignCustomStyle}`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
if (apiBaseURL) {
|
||||
Network.axios.defaults.baseURL = apiBaseURL;
|
||||
@@ -66,7 +79,10 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
app.use(router);
|
||||
await router.isReady();
|
||||
await import("tdesign-mobile-vue");
|
||||
await import("../../timi-web/src/index");
|
||||
await import("timi-tdesign-mobile");
|
||||
mountLibraryOverrideStyle();
|
||||
app.mount("#root");
|
||||
}
|
||||
|
||||
|
||||
@@ -54,58 +54,65 @@
|
||||
/>
|
||||
<t-button size="extra-small" variant="text" @click="clearTagFilter">清空</t-button>
|
||||
</div>
|
||||
<t-cell-group v-if="dateFilterType !== 'all'" class="date-summary" theme="card">
|
||||
<t-cell
|
||||
title="开始时间"
|
||||
:note="dateRange[0] || '-'"
|
||||
arrow
|
||||
@click="openStartTime"
|
||||
/>
|
||||
<t-cell
|
||||
title="结束时间"
|
||||
:note="dateRange[1] || '-'"
|
||||
arrow
|
||||
@click="openEndTime"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<t-pull-down-refresh
|
||||
class="list-wrapper fix-last-cell-line"
|
||||
class="fix-last-cell-line"
|
||||
v-model="isRefreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="refresh"
|
||||
@scrolltolower="fetch"
|
||||
>
|
||||
<t-cell-group class="items" theme="card">
|
||||
<t-swipe-cell
|
||||
v-entrance-transition="index * 30"
|
||||
v-for="(item, index) in pageResult.list"
|
||||
:key="item.id || item.code || index"
|
||||
class="item"
|
||||
>
|
||||
<customer-cell
|
||||
class="cell"
|
||||
:customer="item"
|
||||
<div class="list-wrap">
|
||||
<t-cell-group class="summary" theme="card">
|
||||
<t-cell
|
||||
v-if="dateFilterType !== 'all'"
|
||||
title="开始时间"
|
||||
:note="dateRange[0] || '-'"
|
||||
arrow
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<template #description>
|
||||
<telephone-link :value="item.telephone" />
|
||||
</template>
|
||||
</customer-cell>
|
||||
<template v-if="canDeleteCustomer" #right>
|
||||
<t-button
|
||||
class="delete"
|
||||
theme="danger"
|
||||
:loading="isRemoving && removingCustomerId === item.id"
|
||||
@click.stop="openRemoveDialog(item)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
@click="openStartTime"
|
||||
/>
|
||||
<t-cell
|
||||
v-if="dateFilterType !== 'all'"
|
||||
title="结束时间"
|
||||
:note="dateRange[1] || '-'"
|
||||
arrow
|
||||
@click="openEndTime"
|
||||
/>
|
||||
<t-cell v-if="keyword" title="关键词" :note="keyword" />
|
||||
<t-cell v-if="selectedTagList.length" title="标签" :note="tagFilterText" />
|
||||
<t-cell title="数据量" :note="`共 ${pageResult.total} 位客户`" />
|
||||
</t-cell-group>
|
||||
<div v-if="!pageResult.list.length" class="center-tips" v-text="isLoading ? '正在加载客户' : '暂无客户数据'" />
|
||||
<div v-if="pageResult.list.length" class="center-tips" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
|
||||
<t-cell-group class="items" theme="card">
|
||||
<t-swipe-cell
|
||||
v-entrance-transition="index * 30"
|
||||
v-for="(item, index) in pageResult.list"
|
||||
:key="item.id || item.code || index"
|
||||
class="item"
|
||||
>
|
||||
<customer-cell
|
||||
class="cell"
|
||||
:customer="item"
|
||||
arrow
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<template #description>
|
||||
<telephone-link :value="item.telephone" />
|
||||
</template>
|
||||
</customer-cell>
|
||||
<template v-if="canDeleteCustomer" #right>
|
||||
<t-button
|
||||
class="delete"
|
||||
theme="danger"
|
||||
:loading="isRemoving && removingCustomerId === item.id"
|
||||
@click.stop="openRemoveDialog(item)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
</t-cell-group>
|
||||
<div v-if="!pageResult.list.length" class="center-tips" v-text="isLoading ? '正在加载客户' : '暂无客户数据'" />
|
||||
<div v-if="pageResult.list.length" class="center-tips" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
|
||||
</div>
|
||||
</t-pull-down-refresh>
|
||||
<tag-selector
|
||||
v-model="selectedTagIdList"
|
||||
@@ -600,9 +607,29 @@ function parseDateTime(value: string, isEnd: boolean): Date {
|
||||
}
|
||||
}
|
||||
|
||||
.list-wrapper {
|
||||
.list-wrap {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 0;
|
||||
|
||||
.summary {
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.t-cell__title) {
|
||||
flex: 0 0 4.5rem;
|
||||
}
|
||||
|
||||
:deep(.t-cell__note) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.delete {
|
||||
height: 100%;
|
||||
padding: 0 1rem;
|
||||
@@ -630,6 +657,12 @@ function parseDateTime(value: string, isEnd: boolean): Date {
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: #e2e8f0;
|
||||
background: rgba(30, 41, 59, .9);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
|
||||
.items {
|
||||
|
||||
.center-tips,
|
||||
|
||||
@@ -25,9 +25,19 @@
|
||||
<div class="main">
|
||||
<div class="title">
|
||||
<div class="heading">
|
||||
<strong v-text="item.productNameSnapshot || '未命名产品'" />
|
||||
<t-tag v-if="item.gifted" size="small" theme="success" content="赠送" />
|
||||
<span v-text="getItemDescription(item)" />
|
||||
<div class="name-gift">
|
||||
<strong
|
||||
class="clip-text"
|
||||
v-text="item.productNameSnapshot || '未命名产品'"
|
||||
/>
|
||||
<t-tag
|
||||
v-if="item.gifted"
|
||||
size="small"
|
||||
theme="primary"
|
||||
content="赠送"
|
||||
/>
|
||||
</div>
|
||||
<span class="can-fulfill" v-text="getItemDescription(item)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="item.skuCodeSnapshot || item.skuSpecSnapshot?.length || item.skuNameSnapshot || item.specSummarySnapshot || item.unitSnapshot"
|
||||
@@ -427,9 +437,18 @@ onUnmounted(() => navBarStore.clearRight());
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.t-form__controls),
|
||||
:deep(.t-form__controls-content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: .5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
|
||||
.photo {
|
||||
@@ -444,12 +463,14 @@ onUnmounted(() => navBarStore.clearRight());
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
gap: .375rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -460,14 +481,23 @@ onUnmounted(() => navBarStore.clearRight());
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
|
||||
strong {
|
||||
.name-gift {
|
||||
gap: .5rem;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
strong {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
.can-fulfill {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -53,6 +53,19 @@
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<div class="list-wrap">
|
||||
<t-cell-group v-if="salesOrder" class="order-info" theme="card">
|
||||
<t-cell title="订单号" :note="salesOrder.orderNo || salesOrder.id || '未知订单'" />
|
||||
<t-cell title="订单类型" :note="getSalesOrderTypeText(salesOrder.orderType)" />
|
||||
<t-cell title="订单状态">
|
||||
<template #note>
|
||||
<t-tag
|
||||
:content="getSalesOrderStatusText(salesOrder.status)"
|
||||
:theme="getStatusTheme(salesOrder.status)"
|
||||
variant="light-outline"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="isCustomDate" class="date-summary" theme="card">
|
||||
<t-cell
|
||||
title="开始时间"
|
||||
@@ -120,6 +133,7 @@ import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import FulfillmentAPI from "@/api/FulfillmentAPI";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
|
||||
import OrderFulfillmentListCell from "@/pages/fulfillment/components/OrderFulfillmentListCell.vue";
|
||||
@@ -127,9 +141,11 @@ import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoFulfillmentStatus, OrderFulfillment } from "@/types/Fulfillment";
|
||||
import type { NavBarAction } from "@/types/NavBarAction";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { SalesOrder } from "@/types/SalesOrder";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { getSalesOrderStatusText, getSalesOrderTypeText, getStatusTheme } from "@/utils/salesOrder";
|
||||
|
||||
defineOptions({ name: "OrderFulfillmentList" });
|
||||
|
||||
@@ -167,6 +183,7 @@ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
|
||||
|
||||
const customerId = ref(getQueryText(route.query.customerId));
|
||||
const selectedCustomer = ref<Customer>();
|
||||
const salesOrder = ref<SalesOrder>();
|
||||
const customerSelectorVisible = ref(false);
|
||||
const customerSearchKeyword = ref("");
|
||||
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
|
||||
@@ -331,6 +348,20 @@ async function clearCustomer(): Promise<void> {
|
||||
await reloadList();
|
||||
}
|
||||
|
||||
async function loadSalesOrder(): Promise<void> {
|
||||
const salesOrderId = getQueryText(route.query.salesOrderId);
|
||||
if (!salesOrderId) {
|
||||
salesOrder.value = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
salesOrder.value = await SalesOrderAPI.detail(salesOrderId);
|
||||
} catch (error) {
|
||||
salesOrder.value = undefined;
|
||||
Message.error(getErrorMessage(error, "订单信息加载失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDateFilter(value: DropdownValue): Promise<void> {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
@@ -513,6 +544,7 @@ onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
navBarStore.setCustomRightVisible(canCreate.value);
|
||||
try {
|
||||
await loadSalesOrder();
|
||||
if (customerId.value) {
|
||||
selectedCustomer.value = await CustomerAPI.detail(customerId.value);
|
||||
customerSearchKeyword.value = getCustomerSearchKeyword(selectedCustomer.value);
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.eventRecord.create" class="quick-sign-in-page">
|
||||
<space class="content-margin" direction="vertical" v-entrance-transition>
|
||||
<div class="event-panel">
|
||||
<event-selector
|
||||
v-if="!isEventLoading && !eventLoadFailed"
|
||||
:model-value="selectedEventIdList"
|
||||
:event-list="eventList"
|
||||
required-mark
|
||||
@update:model-value="handleEventSelectionChange"
|
||||
/>
|
||||
<div v-else-if="isEventLoading" class="empty-block">正在加载登记事件</div>
|
||||
<div v-else class="event-load-error">
|
||||
<span>登记事件加载失败</span>
|
||||
<t-button size="small" theme="primary" variant="text" @click="loadActions">重试</t-button>
|
||||
</div>
|
||||
<div class="tips red">事件可多选,请注意勾选的登记事件</div>
|
||||
</div>
|
||||
<t-cell-group v-if="!isEventLoading && !eventLoadFailed" theme="card">
|
||||
<t-cell title="登记事件" arrow @click="eventPopupVisible = true">
|
||||
<template #left-icon>
|
||||
<t-icon name="task-setting" />
|
||||
</template>
|
||||
<template #note>
|
||||
<span class="red text-bold" v-text="selectedEventName" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell title="识别成功自动登记">
|
||||
<template #left-icon>
|
||||
<t-icon name="task-checked" />
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<t-switch v-model="autoRegisterEnabled" />
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="red">扫码匹配客户后自动提交登记,请注意已选登记事件和选项</div>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else-if="isEventLoading" class="center-tips">正在加载登记事件</div>
|
||||
<div v-else class="center-tips">登记事件加载失败</div>
|
||||
<div v-if="selectedEventList && selectedEventList[0]?.optionGroupList?.length !== 0" class="content-margin">
|
||||
<event-option-selector
|
||||
v-model="selectedOptionSelectionMap"
|
||||
:event-list="eventList"
|
||||
:selected-event-id-list="selectedEventIdList"
|
||||
/>
|
||||
</div>
|
||||
<div class="content-margin">
|
||||
<code-scanner
|
||||
visible
|
||||
:session-key="scannerSessionKey"
|
||||
@@ -29,14 +39,14 @@
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</space>
|
||||
</div>
|
||||
<t-button
|
||||
class="cell-button"
|
||||
theme="primary"
|
||||
:disabled="!canSubmitRegister"
|
||||
:loading="submitting"
|
||||
v-entrance-transition="70"
|
||||
@click="doSubmit"
|
||||
@click="doSubmit()"
|
||||
>登记</t-button>
|
||||
<customer-profile-card
|
||||
v-if="matchedCustomer"
|
||||
@@ -110,6 +120,29 @@
|
||||
@change="handleRecordPhotoSelected"
|
||||
>
|
||||
</div>
|
||||
<bottom-popup
|
||||
v-model="eventPopupVisible"
|
||||
title="选择登记事件"
|
||||
left-button-text="关闭"
|
||||
right-button-text=""
|
||||
@left-button-click="eventPopupVisible = false"
|
||||
>
|
||||
<div class="event-popup">
|
||||
<event-selector
|
||||
v-if="!isEventLoading && !eventLoadFailed"
|
||||
:model-value="selectedEventIdList"
|
||||
:event-list="eventList"
|
||||
selection-mode="single"
|
||||
label=""
|
||||
@update:model-value="handleEventSelectionChange"
|
||||
/>
|
||||
<div v-else-if="isEventLoading" class="empty-block">正在加载登记事件</div>
|
||||
<div v-else class="event-load-error">
|
||||
<span>登记事件加载失败</span>
|
||||
<t-button size="small" theme="primary" variant="text" @click="loadActions">重试</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -133,9 +166,9 @@ import { permissionCodes } from "@/utils/permission";
|
||||
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
|
||||
import { compressUploadImageList } from "@/utils/imageCompression";
|
||||
import { Storage, Time } from "timi-web";
|
||||
import { AttachmentAPI, type Attachment } from "timi-web-api";
|
||||
import { type Attachment, AttachmentAPI } from "timi-web-api";
|
||||
import EventSelector from "@/components/EventSelector.vue";
|
||||
import { Space } from "timi-tdesign-mobile";
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
|
||||
defineOptions({
|
||||
name: "QuickSignInPage"
|
||||
@@ -147,14 +180,17 @@ const settingStore = useSettingStore();
|
||||
// ---------- 登记事件 ----------
|
||||
|
||||
const EventItemStorageKey = "quick-scan-event-id";
|
||||
const autoRegisterEnabled = ref(false);
|
||||
const selectedEventIdList = ref<string[]>([]);
|
||||
const eventList = ref<EventItem[]>([]);
|
||||
const selectedOptionSelectionMap = reactive<EventOptionSelectionMap>({});
|
||||
const isEventLoading = ref(false);
|
||||
const eventLoadFailed = ref(false);
|
||||
const eventPopupVisible = ref(false);
|
||||
const selectedEventList = computed(() => selectedEventIdList.value
|
||||
.map(eventId => eventList.value.find(item => normalizeEventId(item) === eventId))
|
||||
.filter((item): item is EventItem => !!item));
|
||||
const selectedEventName = computed(() => selectedEventList.value[0]?.name || "请选择登记事件");
|
||||
const selectedNumberEventList = computed(() => selectedEventList.value.filter(item => item.valueType === "NUMBER"));
|
||||
const selectedRequiredPhotoEventList = computed(() => selectedEventList.value.filter(item => item.requirePhoto === true));
|
||||
|
||||
@@ -200,12 +236,13 @@ function buildInitialSelectedEventIdList(): string[] {
|
||||
|
||||
function normalizeSelectedEventIdList(eventIdList: string[]): string[] {
|
||||
const validEventIdSet = new Set(eventList.value.map(normalizeEventId).filter(Boolean));
|
||||
return [...new Set(eventIdList.map(String))].filter(eventId => validEventIdSet.has(eventId));
|
||||
return [...new Set(eventIdList.map(String))].filter(eventId => validEventIdSet.has(eventId)).slice(0, 1);
|
||||
}
|
||||
|
||||
async function handleEventSelectionChange(eventIdList: string[]): Promise<void> {
|
||||
selectedEventIdList.value = normalizeSelectedEventIdList(eventIdList);
|
||||
syncStoredEventId();
|
||||
eventPopupVisible.value = false;
|
||||
}
|
||||
|
||||
function resolveRecordOptionGroupList(eventId: string) {
|
||||
@@ -243,6 +280,7 @@ const scanCode = ref("");
|
||||
const scanStatus = ref("正在启动扫码预览");
|
||||
const scannerSessionKey = ref(0);
|
||||
const matchedCustomer = ref<Customer>();
|
||||
const registeredCustomerIdSet = new Set<string>();
|
||||
let latestDecodedText = "";
|
||||
|
||||
async function handleDecodedText(rawText: string): Promise<void> {
|
||||
@@ -254,8 +292,16 @@ async function handleDecodedText(rawText: string): Promise<void> {
|
||||
scanCode.value = normalizedText;
|
||||
await settingStore.playScanSuccessSound();
|
||||
await loadCustomer(normalizedText);
|
||||
if (matchedCustomer.value) {
|
||||
const customer = matchedCustomer.value;
|
||||
if (customer) {
|
||||
if (customer.id && registeredCustomerIdSet.has(customer.id)) {
|
||||
scanStatus.value = `客户 ${customer.name || customer.code || normalizedText} 已在本页面登记`;
|
||||
return;
|
||||
}
|
||||
scanStatus.value = `已识别 ${normalizedText},可直接登记`;
|
||||
if (autoRegisterEnabled.value) {
|
||||
await doSubmit(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
scanStatus.value = `已识别 ${normalizedText},但未匹配客户档案`;
|
||||
@@ -399,11 +445,19 @@ function buildPhotoAttachmentList(): Array<Partial<Attachment>> {
|
||||
}));
|
||||
}
|
||||
|
||||
async function doSubmit(): Promise<void> {
|
||||
async function doSubmit(isAutoRegister = false): Promise<void> {
|
||||
const customer = matchedCustomer.value;
|
||||
if (!customer) {
|
||||
return;
|
||||
}
|
||||
const customerId = customer.id;
|
||||
if (!customerId) {
|
||||
return;
|
||||
}
|
||||
if (registeredCustomerIdSet.has(customerId)) {
|
||||
Message.warning("该客户已在本页面登记,请勿重复登记");
|
||||
return;
|
||||
}
|
||||
if (!selectedEventIdList.value.length) {
|
||||
Message.warning("请至少选择一个登记事件");
|
||||
return;
|
||||
@@ -425,21 +479,28 @@ async function doSubmit(): Promise<void> {
|
||||
try {
|
||||
await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({
|
||||
eventId,
|
||||
customerId: customer.id,
|
||||
customerId,
|
||||
selectedOptionGroupList: resolveRecordOptionGroupList(eventId),
|
||||
numberValue: resolveNumberValue(eventId),
|
||||
remark: "客户快速扫码登记",
|
||||
attachmentList: buildPhotoAttachmentList()
|
||||
})));
|
||||
scanStatus.value = `登记成功:${customer.name || customer.code || scanCode.value}。`;
|
||||
registeredCustomerIdSet.add(customerId);
|
||||
const customerText = customer.name || customer.code || scanCode.value || "客户";
|
||||
scanStatus.value = `登记成功:${customerText}。`;
|
||||
if (isAutoRegister) {
|
||||
Message.success(`自动登记成功:${customerText}`);
|
||||
}
|
||||
scanCode.value = "";
|
||||
matchedCustomer.value = undefined;
|
||||
recordPhotoItemList.value = [];
|
||||
for (const eventId of Object.keys(numberValueTextMap)) {
|
||||
delete numberValueTextMap[eventId];
|
||||
}
|
||||
for (const eventId of Object.keys(selectedOptionSelectionMap)) {
|
||||
delete selectedOptionSelectionMap[eventId];
|
||||
if (!isAutoRegister) {
|
||||
for (const eventId of Object.keys(selectedOptionSelectionMap)) {
|
||||
delete selectedOptionSelectionMap[eventId];
|
||||
}
|
||||
}
|
||||
recentRecordList.value = [];
|
||||
latestDecodedText = "";
|
||||
@@ -459,18 +520,6 @@ async function doSubmit(): Promise<void> {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.event-panel {
|
||||
padding: .75rem 1rem;
|
||||
background: rgba(255, 255, 255, .94);
|
||||
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||
border-radius: .875rem;
|
||||
|
||||
.tips {
|
||||
margin-top: .5rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.input-form {
|
||||
padding: .5rem 0;
|
||||
background: rgba(255, 255, 255, .94);
|
||||
@@ -492,24 +541,6 @@ async function doSubmit(): Promise<void> {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-block,
|
||||
.event-load-error {
|
||||
color: #52607a;
|
||||
min-height: 4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.event-load-error {
|
||||
gap: .5rem;
|
||||
display: grid;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-box {
|
||||
padding: .875rem;
|
||||
background: #f7faff;
|
||||
@@ -547,6 +578,10 @@ async function doSubmit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
.event-popup {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.quick-sign-in-page {
|
||||
.panel,
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<div class="description" v-text="config.description" />
|
||||
</div>
|
||||
</t-form-item>
|
||||
<t-form-item label="成交价(元)" label-width="6rem" required-mark>
|
||||
<t-form-item label="成交价" label-width="6rem" required-mark>
|
||||
<t-input
|
||||
v-model.trim="saleAmountText"
|
||||
type="number"
|
||||
@@ -170,6 +170,59 @@
|
||||
/>
|
||||
</div>
|
||||
</t-form-item>
|
||||
<t-form-item
|
||||
v-if="canReceiveImmediately"
|
||||
class="payment-item"
|
||||
:class="{ disabled: receiveFullPayment }"
|
||||
:label="receiveFullPayment ? '收款和核销金额' : '已部分收款和核销'"
|
||||
label-width="9rem"
|
||||
content-align="right"
|
||||
>
|
||||
<t-input
|
||||
:model-value="receiveFullPayment ? formatPackageAmount(parsedSaleAmount || 0) : paymentAmountText"
|
||||
type="number"
|
||||
align="right"
|
||||
borderless
|
||||
:disabled="receiveFullPayment"
|
||||
:placeholder="paymentAmountPlaceholder"
|
||||
@update:model-value="paymentAmountText = String($event)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="已全额收款和核销" content-align="right">
|
||||
<t-switch
|
||||
v-model="receiveFullPayment"
|
||||
size="small"
|
||||
:disabled="!canReceiveImmediately"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="已全部产品履约和核销" content-align="right">
|
||||
<t-switch
|
||||
v-model="fulfillAll"
|
||||
size="small"
|
||||
:disabled="!canFulfillImmediately"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label-width="0">
|
||||
<div class="red">全部履约会同步启用全额收款;请先准备全部产品库存,任意一步失败,整张订单都会回滚。</div>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="(paymentVerified && parsedPaymentAmount) || (receiveFullPayment && parsedSaleAmount)" label-width="0">
|
||||
<photo-scroller
|
||||
:model-value="paymentPhotoUrlList"
|
||||
v-model:photo-items="paymentPhotoItems"
|
||||
:upload="uploadPhotoList"
|
||||
:edit-mode="true"
|
||||
title="收款凭证照片(必要)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="fulfillAll" label-width="0">
|
||||
<photo-scroller
|
||||
:model-value="fulfillmentPhotoUrlList"
|
||||
v-model:photo-items="fulfillmentPhotoItems"
|
||||
:upload="uploadPhotoList"
|
||||
:edit-mode="true"
|
||||
title="履约凭证照片(必要)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="购买备注">
|
||||
<t-textarea
|
||||
v-model.trim="note"
|
||||
@@ -195,26 +248,48 @@
|
||||
:enable-scan="false"
|
||||
@change="handleCustomerChange"
|
||||
/>
|
||||
<t-popup v-model="configSelectorVisible" placement="bottom">
|
||||
<div class="config-selector bg-white">
|
||||
<div class="popup-header">
|
||||
<t-button variant="text" size="small" @click="configSelectorVisible = false">关闭</t-button>
|
||||
<h3>选择套餐</h3>
|
||||
<span />
|
||||
</div>
|
||||
<bottom-popup
|
||||
v-model="configSelectorVisible"
|
||||
title="选择套餐"
|
||||
height="70vh"
|
||||
right-button-text=""
|
||||
@left-button-click="configSelectorVisible = false"
|
||||
>
|
||||
<div class="config-selector fix-cell-title-clip empty-cell-title-margin">
|
||||
<t-cell-group v-if="configList.length">
|
||||
<t-cell
|
||||
v-for="item in configList"
|
||||
:key="item.id"
|
||||
:title="item.name || '未命名套餐'"
|
||||
:note="`标准价:${formatPackageAmount(item.standardAmount)}`"
|
||||
class="config-option"
|
||||
arrow
|
||||
@click="selectConfig(item)"
|
||||
/>
|
||||
>
|
||||
<template #title>
|
||||
<div class="title">
|
||||
<div class="clip-text" v-text="item.name || '未命名套餐'" />
|
||||
<div class="price keep-text">
|
||||
<span class="label gray">标准价:</span>
|
||||
<span class="red" v-text="formatPackageAmount(item.standardAmount)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="products">
|
||||
<t-tag
|
||||
v-for="productItem in item.itemList || []"
|
||||
:key="productItem.id || `${productItem.skuId}-${productItem.skuUnitId}`"
|
||||
:content="getConfigProductText(productItem)"
|
||||
theme="primary"
|
||||
variant="light"
|
||||
/>
|
||||
<t-tag v-if="!item.itemList?.length" content="暂无产品权益" />
|
||||
</div>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="center-tips" v-text="'暂无可购买套餐'" />
|
||||
</div>
|
||||
</t-popup>
|
||||
</bottom-popup>
|
||||
<t-popup v-model="unitPickerVisible" placement="bottom">
|
||||
<t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="调整权益单位" @cancel="unitPickerVisible = false" @confirm="confirmItemUnitPicker" />
|
||||
</t-popup>
|
||||
@@ -252,6 +327,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import ProductAPI from "@/api/ProductAPI";
|
||||
@@ -265,14 +342,16 @@ import { useNavBarStore } from "@/store/navBarStore";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import type { GaoPackageConfig, PackageAttachment, PackagePurchaseItem } from "@/types/Package";
|
||||
import type { GaoPackageConfig, PackageAttachment, PackageConfigItem, PackagePurchaseItem } from "@/types/Package";
|
||||
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import {
|
||||
addPackageQuantity,
|
||||
convertPackageQuantity,
|
||||
formatPackageAmount,
|
||||
normalizePackageQuantity
|
||||
getPackageQuantityText,
|
||||
normalizePackageQuantity,
|
||||
parsePackageAmount
|
||||
} from "@/utils/package";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
@@ -423,10 +502,21 @@ function parseDateTime(value: string): Date {
|
||||
const note = ref("");
|
||||
const isSaving = ref(false);
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const paymentPhotoItems = ref<PhotoItem[]>([]);
|
||||
const fulfillmentPhotoItems = ref<PhotoItem[]>([]);
|
||||
const paymentAmountText = ref("0");
|
||||
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
|
||||
const paymentPhotoUrlList = computed(() => paymentPhotoItems.value.map(item => item.url));
|
||||
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
|
||||
const receiveFullPayment = ref(false);
|
||||
const fulfillAll = ref(false);
|
||||
const parsedPaymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
|
||||
const paymentVerified = computed(() => !receiveFullPayment.value
|
||||
&& 0 < paymentAmountText.value.trim().length
|
||||
&& parsedPaymentAmount.value !== 0);
|
||||
|
||||
function buildAttachments(): PackageAttachment[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{
|
||||
function buildAttachments(source: PhotoItem[] = photoItems.value): PackageAttachment[] {
|
||||
return source.flatMap(item => item.tempFileId ? [{
|
||||
tempFileId: item.tempFileId,
|
||||
attachType: item.attachType || "PHOTO"
|
||||
}] : []);
|
||||
@@ -446,9 +536,35 @@ const {
|
||||
config,
|
||||
selectedCustomerList,
|
||||
note,
|
||||
paymentVerified,
|
||||
paymentAmount: parsedPaymentAmount,
|
||||
receiveFullPayment,
|
||||
fulfillAll,
|
||||
hasPermission: permissionCode => userStore.hasPermissions(permissionCode),
|
||||
buildRegisteredAt: buildPurchasedAt,
|
||||
buildAttachments
|
||||
buildAttachments,
|
||||
buildPaymentAttachments: () => buildAttachments(paymentPhotoItems.value),
|
||||
buildFulfillmentAttachments: () => buildAttachments(fulfillmentPhotoItems.value)
|
||||
});
|
||||
|
||||
const parsedSaleAmount = computed(() => parsePackageAmount(saleAmountText.value));
|
||||
const paymentAmountPlaceholder = computed(() => `订单总金额:${formatPackageAmount(parsedSaleAmount.value || 0)}`);
|
||||
const canReceiveImmediately = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.create)
|
||||
&& userStore.hasPermissions(permissionCodes.fundReceive.verify));
|
||||
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
|
||||
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
|
||||
&& (parsedSaleAmount.value === 0 || canReceiveImmediately.value));
|
||||
|
||||
watch(fulfillAll, value => {
|
||||
if (value && parsedSaleAmount.value !== 0) {
|
||||
receiveFullPayment.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
watch(receiveFullPayment, value => {
|
||||
if (!value && parsedSaleAmount.value !== 0 && (!paymentVerified.value || parsedPaymentAmount.value !== parsedSaleAmount.value)) {
|
||||
fulfillAll.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const unitPickerOptions = computed(() => {
|
||||
@@ -456,6 +572,12 @@ const unitPickerOptions = computed(() => {
|
||||
return item ? getSelectableUnitList(item).map(unit => ({ label: unit.name, value: unit.id || "" })) : [];
|
||||
});
|
||||
|
||||
function getConfigProductText(item: PackageConfigItem): string {
|
||||
const quantity = getPackageQuantityText(item.defaultQuantity);
|
||||
const skuText = getProductSkuSnapshotText(item.productNameSnapshot, item.skuNameSnapshot, item.specSummarySnapshot);
|
||||
return `${item.productNameSnapshot || "未命名产品"}${skuText ? ` · ${skuText}` : ""} × ${quantity}${item.unitSnapshot ? ` ${item.unitSnapshot}` : ""}`;
|
||||
}
|
||||
|
||||
function getItemLabel(item: PackagePurchaseItem): string {
|
||||
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId);
|
||||
const product = item.gifted ? giftProductMap[item.skuId] : undefined;
|
||||
@@ -647,6 +769,30 @@ async function submitRecord(): Promise<void> {
|
||||
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
|
||||
return;
|
||||
}
|
||||
if (paymentVerified.value || receiveFullPayment.value || fulfillAll.value) {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "确认直通完成交易",
|
||||
content: fulfillAll.value
|
||||
? "将一次性购买套餐、完成收款核销,并完成全部履约和库存扣减。提交后如需更正,只能走撤销或冲正流程。"
|
||||
: paymentVerified.value
|
||||
? "将一次性购买套餐并登记已核销收款。提交后如需更正,只能走资金冲正流程。"
|
||||
: "将一次性购买套餐并核销全额收款。提交后如需更正,只能走资金冲正流程。",
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "确认并提交",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
await purchasePackage();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
await purchasePackage();
|
||||
}
|
||||
|
||||
async function purchasePackage(): Promise<void> {
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
@@ -656,7 +802,7 @@ async function submitRecord(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
await settingStore.playRegisterSuccessSound();
|
||||
Message.success("套餐购买已提交");
|
||||
Message.success(fulfillAll.value ? "套餐、收款及履约已完成" : paymentVerified.value ? "套餐及已核销收款已完成" : receiveFullPayment.value ? "套餐及全额收款已完成" : "套餐购买已提交");
|
||||
await router.replace({
|
||||
name: "SalesOrderDetail",
|
||||
params: { id: result.salesOrderId },
|
||||
@@ -749,6 +895,25 @@ watch(config, value => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.straight-through {
|
||||
width: 100%;
|
||||
padding: .75rem;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: .875rem;
|
||||
|
||||
.title {
|
||||
font-size: .9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tips {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin: .5rem .5rem 0;
|
||||
font-size: .75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
margin: 1rem;
|
||||
}
|
||||
@@ -809,15 +974,14 @@ watch(config, value => {
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
padding: 6px;
|
||||
box-shadow: .25rem .25rem 1rem rgba(0, 0, 0, .08);
|
||||
border-radius: 1rem;
|
||||
|
||||
.avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: none;
|
||||
border-radius: .5rem;
|
||||
border-radius: .5rem !important;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@@ -860,6 +1024,14 @@ watch(config, value => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.payment-item.disabled {
|
||||
color: var(--td-text-color-disabled);
|
||||
|
||||
:deep(.t-form__label) {
|
||||
color: var(--td-text-color-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-actions {
|
||||
@@ -874,14 +1046,40 @@ watch(config, value => {
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.package-purchase-form .avatar .t-image__img) {
|
||||
width: 100%;
|
||||
.config-selector {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
overflow: auto;
|
||||
|
||||
.config-option {
|
||||
|
||||
.title {
|
||||
gap: .5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.price {
|
||||
flex: none;
|
||||
display: flex;
|
||||
|
||||
.label {
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.products {
|
||||
gap: .35rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-selector,
|
||||
.selected-customer-popup {
|
||||
height: 70vh;
|
||||
overflow: auto;
|
||||
|
||||
@@ -10,9 +10,15 @@ interface UsePackagePurchaseOptions {
|
||||
config: Ref<GaoPackageConfig | undefined>;
|
||||
selectedCustomerList: Ref<Customer[]>;
|
||||
note: Ref<string>;
|
||||
paymentVerified: Ref<boolean>;
|
||||
paymentAmount: Ref<number | undefined>;
|
||||
receiveFullPayment: Ref<boolean>;
|
||||
fulfillAll: Ref<boolean>;
|
||||
hasPermission: (permissionCode: string) => boolean;
|
||||
buildRegisteredAt: () => number;
|
||||
buildAttachments: () => PackageAttachment[];
|
||||
buildPaymentAttachments: () => PackageAttachment[];
|
||||
buildFulfillmentAttachments: () => PackageAttachment[];
|
||||
}
|
||||
|
||||
/** 套餐配置直接购买流程,购买请求只依赖套餐配置 ID,不再关联购买事件。 */
|
||||
@@ -91,12 +97,21 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
|
||||
saleAmount,
|
||||
purchasedAt: options.buildRegisteredAt(),
|
||||
attachmentList: options.buildAttachments(),
|
||||
straightThrough: options.paymentVerified.value || options.receiveFullPayment.value || options.fulfillAll.value ? {
|
||||
paymentVerified: options.paymentVerified.value,
|
||||
paymentAmount: options.paymentAmount.value,
|
||||
receiveFullPayment: options.receiveFullPayment.value,
|
||||
paymentAttachmentList: options.buildPaymentAttachments(),
|
||||
fulfillAll: options.fulfillAll.value,
|
||||
fulfillmentAttachmentList: options.buildFulfillmentAttachments()
|
||||
} : undefined,
|
||||
itemList,
|
||||
remark: options.note.value.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
function validatePurchaseForm(): boolean {
|
||||
const saleAmount = parsePackageAmount(saleAmountText.value);
|
||||
if (!options.hasPermission(permissionCodes.customerPackage.create)) {
|
||||
Message.warning("当前账号没有套餐购买权限");
|
||||
return false;
|
||||
@@ -113,7 +128,7 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
|
||||
Message.warning("套餐当前不可购买,请检查套餐状态和有效期");
|
||||
return false;
|
||||
}
|
||||
if (parsePackageAmount(saleAmountText.value) === undefined) {
|
||||
if (saleAmount === undefined) {
|
||||
Message.warning("成交价必须是合法的元金额,最多两位小数");
|
||||
return false;
|
||||
}
|
||||
@@ -139,6 +154,39 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
|
||||
Message.warning("套餐至少需要保留一项正数量权益");
|
||||
return false;
|
||||
}
|
||||
const canReceiveImmediately = options.hasPermission(permissionCodes.fundReceive.create)
|
||||
&& options.hasPermission(permissionCodes.fundReceive.verify);
|
||||
const canFulfillImmediately = options.hasPermission(permissionCodes.orderFulfillment.create)
|
||||
&& options.hasPermission(permissionCodes.orderFulfillment.verify)
|
||||
&& (saleAmount === 0 || canReceiveImmediately);
|
||||
if ((options.paymentVerified.value || options.receiveFullPayment.value) && !canReceiveImmediately) {
|
||||
Message.warning("当前账号没有收款登记和核销权限");
|
||||
return false;
|
||||
}
|
||||
if (options.paymentVerified.value && (options.paymentAmount.value === undefined || options.paymentAmount.value <= 0 || options.paymentAmount.value > saleAmount)) {
|
||||
Message.warning("已核销收款金额必须大于 0 且不能超过成交价");
|
||||
return false;
|
||||
}
|
||||
if (options.fulfillAll.value && !canFulfillImmediately) {
|
||||
Message.warning("当前账号没有交易直通所需权限");
|
||||
return false;
|
||||
}
|
||||
if (options.fulfillAll.value && saleAmount !== 0 && !options.paymentVerified.value && !options.receiveFullPayment.value) {
|
||||
Message.warning("全部履约直通必须同时完成收款核销");
|
||||
return false;
|
||||
}
|
||||
if (options.fulfillAll.value && options.paymentVerified.value && options.paymentAmount.value !== saleAmount) {
|
||||
Message.warning("全部履约直通必须先完成全额收款核销");
|
||||
return false;
|
||||
}
|
||||
if ((options.paymentVerified.value || options.receiveFullPayment.value) && saleAmount !== 0 && !options.buildPaymentAttachments().length) {
|
||||
Message.warning("收款核销必须上传收款凭证照片");
|
||||
return false;
|
||||
}
|
||||
if (options.fulfillAll.value && !options.buildFulfillmentAttachments().length) {
|
||||
Message.warning("全部履约直通必须上传履约凭证照片");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
</t-input>
|
||||
</div>
|
||||
<t-pull-down-refresh
|
||||
class="list-wrapper"
|
||||
v-model="isRefreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="reload"
|
||||
@scrolltolower="load"
|
||||
>
|
||||
<t-cell-group v-if="pageResult.list.length" class="list" theme="card">
|
||||
<div class="list-wrap">
|
||||
<t-cell-group v-if="pageResult.list.length" class="list" theme="card">
|
||||
<customer-cell
|
||||
v-entrance-transition="index * 30"
|
||||
v-for="(item, index) in pageResult.list"
|
||||
@@ -44,8 +44,9 @@
|
||||
v-text="formatPoints(item.balance)" />
|
||||
</template>
|
||||
</customer-cell>
|
||||
</t-cell-group>
|
||||
<div class="center-tips" v-text="loadStateText" />
|
||||
</t-cell-group>
|
||||
<div class="center-tips" v-text="loadStateText" />
|
||||
</div>
|
||||
</t-pull-down-refresh>
|
||||
<customer-selector
|
||||
v-model="selectedCustomerId"
|
||||
@@ -271,10 +272,15 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.list-wrapper {
|
||||
.list-wrap {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
min-height: 70vh;
|
||||
padding-top: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.list {
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
|
||||
@@ -300,13 +306,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center-tips {
|
||||
color: #52607a;
|
||||
padding: 1rem;
|
||||
font-size: .8125rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedeemSku.manage" class="redeem-sku-edit">
|
||||
<t-form class="form" v-entrance-transition>
|
||||
<product-selector
|
||||
v-model="selection"
|
||||
:selected-product="form.product"
|
||||
required-mark
|
||||
/>
|
||||
<t-form-item label="默认积分" required-mark>
|
||||
<t-input
|
||||
v-model.trim="form.pointText"
|
||||
type="number"
|
||||
align="right"
|
||||
borderless
|
||||
placeholder="每单位所需积分"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="配置状态" content-align="right" arrow @click="statusVisible = true">
|
||||
<span v-text="statusText(form.status)" />
|
||||
</t-form-item>
|
||||
<t-form-item label="累计上限">
|
||||
<t-input v-model.trim="form.totalText" type="number" align="right" borderless placeholder="不限" />
|
||||
</t-form-item>
|
||||
<t-form-item label="每日上限">
|
||||
<t-input v-model.trim="form.dailyText" type="number" align="right" borderless placeholder="不限" />
|
||||
</t-form-item>
|
||||
<t-form-item label="每月上限">
|
||||
<t-input v-model.trim="form.monthlyText" type="number" align="right" borderless placeholder="不限" />
|
||||
</t-form-item>
|
||||
<t-form-item label-width="0">
|
||||
<span class="tips gray">数量上限按兑换业务单位计算,最多三位小数。留空表示不限</span>
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model.trim="form.remark" maxlength="500" autosize />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<div v-if="form.id" class="actions" v-entrance-transition="70">
|
||||
<t-button class="bg-white" theme="danger" variant="outline" @click="disable">停用配置</t-button>
|
||||
<t-button theme="danger" @click="remove">删除</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<t-popup v-model="statusVisible" placement="bottom">
|
||||
<t-picker
|
||||
v-model="statusDraft"
|
||||
:columns="statusOptions"
|
||||
title="配置状态"
|
||||
@cancel="statusVisible = false"
|
||||
@confirm="confirmStatus"
|
||||
/>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import ProductSelector from "@/components/ProductSelector.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { PointRedeemSku, PointRedeemSkuStatus } from "@/types/Point";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { normalizePackageQuantity } from "@/utils/package";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedeemSkuEdit" });
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const selection = ref<{ skuId: string; skuUnitId: string }>();
|
||||
const statusVisible = ref(false);
|
||||
const statusDraft = ref<Array<string | number>>(["DRAFT"]);
|
||||
const isSaving = ref(false);
|
||||
const form = reactive({
|
||||
id: "",
|
||||
product: undefined as PointRedeemSku["product"],
|
||||
pointText: "",
|
||||
status: "DRAFT" as PointRedeemSkuStatus,
|
||||
totalText: "",
|
||||
dailyText: "",
|
||||
monthlyText: "",
|
||||
remark: ""
|
||||
});
|
||||
const statusOptions = [
|
||||
{ label: "草稿", value: "DRAFT" },
|
||||
{ label: "启用", value: "ACTIVE" },
|
||||
{ label: "停用", value: "INACTIVE" }
|
||||
];
|
||||
|
||||
function statusText(status: PointRedeemSkuStatus): string {
|
||||
return ({ DRAFT: "草稿", ACTIVE: "启用", INACTIVE: "停用", DELETED: "已删除" } as Record<string, string>)[status];
|
||||
}
|
||||
|
||||
function parsePoint(): number | undefined {
|
||||
const value = Number(form.pointText);
|
||||
return Number.isSafeInteger(value) && 0 < value ? value : undefined;
|
||||
}
|
||||
|
||||
function parseLimit(value: string): string | null | undefined {
|
||||
return value ? normalizePackageQuantity(value) : null;
|
||||
}
|
||||
|
||||
function confirmStatus(value: Array<string | number>): void {
|
||||
form.status = String(value[0]) as PointRedeemSkuStatus;
|
||||
statusVisible.value = false;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const item = await PointAPI.redeemSkuDetail(id);
|
||||
form.id = item.id || "";
|
||||
form.product = item.product;
|
||||
selection.value = item.skuId && item.skuUnitId ? { skuId: item.skuId, skuUnitId: item.skuUnitId } : undefined;
|
||||
form.pointText = String(item.defaultPointCost || "");
|
||||
form.status = item.status || "DRAFT";
|
||||
form.totalText = item.maxTotalQuantity === null || item.maxTotalQuantity === undefined ? "" : String(item.maxTotalQuantity);
|
||||
form.dailyText = item.maxDailyQuantity === null || item.maxDailyQuantity === undefined ? "" : String(item.maxDailyQuantity);
|
||||
form.monthlyText = item.maxMonthlyQuantity === null || item.maxMonthlyQuantity === undefined ? "" : String(item.maxMonthlyQuantity);
|
||||
form.remark = item.remark || "";
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const point = parsePoint();
|
||||
const total = parseLimit(form.totalText);
|
||||
const daily = parseLimit(form.dailyText);
|
||||
const monthly = parseLimit(form.monthlyText);
|
||||
if (isSaving.value || !selection.value || !point || total === undefined || daily === undefined || monthly === undefined) {
|
||||
Message.warning("请完整填写产品、积分和合法数量上限");
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const data: PointRedeemSku = {
|
||||
id: form.id || undefined,
|
||||
skuId: selection.value.skuId,
|
||||
skuUnitId: selection.value.skuUnitId,
|
||||
status: form.status,
|
||||
defaultPointCost: point,
|
||||
maxTotalQuantity: total,
|
||||
maxDailyQuantity: daily,
|
||||
maxMonthlyQuantity: monthly,
|
||||
remark: form.remark || null
|
||||
};
|
||||
if (form.id) {
|
||||
await PointAPI.redeemSkuUpdate(data);
|
||||
} else {
|
||||
await PointAPI.redeemSkuCreate(data);
|
||||
}
|
||||
Message.success("兑换配置已保存");
|
||||
await router.replace({ name: "PointRedeemSkuList" });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换配置保存失败"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(): Promise<void> {
|
||||
if (!form.id || isSaving.value) {
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await PointAPI.redeemSkuDisable(form.id);
|
||||
Message.success("兑换配置已停用");
|
||||
await router.replace({ name: "PointRedeemSkuList" });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换配置停用失败"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (!form.id || isSaving.value) {
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await PointAPI.redeemSkuRemove(form.id);
|
||||
Message.success("兑换配置已标记删除");
|
||||
await router.replace({ name: "PointRedeemSkuList" });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换配置删除失败"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
|
||||
try {
|
||||
await load();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换配置加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redeem-sku-edit {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
flex-direction: column;
|
||||
|
||||
.tips {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
--td-button-border-radius: 1rem;
|
||||
|
||||
gap: .75rem;
|
||||
margin: 0 1rem;
|
||||
display: flex;
|
||||
|
||||
.t-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedeemSku.manage" class="redeem-sku-list">
|
||||
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="load">
|
||||
<t-cell-group v-if="result.list.length" theme="card">
|
||||
<t-cell
|
||||
v-for="(item, index) in result.list"
|
||||
:key="item.id"
|
||||
arrow
|
||||
v-entrance-transition="index * 30"
|
||||
@click="toEdit(item)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="title">
|
||||
<strong v-text="item.product?.name || item.sku?.name || '未命名产品'" />
|
||||
<t-tag
|
||||
size="small"
|
||||
:theme="item.status === 'ACTIVE' ? 'success' : 'default'"
|
||||
:content="statusText(item.status)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<span
|
||||
v-text="`${item.sku?.code || item.sku?.specSummary || '默认 SKU'} · ${item.unit?.name || '单位未知'} · ${formatPoint(item.defaultPointCost)} / 单位`"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-empty v-else :description="isLoading ? '正在加载兑换配置' : '暂无兑换配置'" />
|
||||
<div class="tips" v-text="isNoMore ? `共 ${result.total} 条配置` : '上拉加载更多'" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
<nav-bar-action-menu
|
||||
v-can="permissionCodes.pointRedeemSku.manage"
|
||||
:actions="[{ key: 'create', icon: 'add', text: '新增兑换配置', theme: 'primary', action: toCreate }]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { PointRedeemSku, PointRedeemSkuStatus } from "@/types/Point";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedeemSkuList" });
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const result = reactive({ total: 0, list: [] as PointRedeemSku[] });
|
||||
const index = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const isNoMore = ref(false);
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
index.value = 0;
|
||||
result.total = 0;
|
||||
result.list = [];
|
||||
isNoMore.value = false;
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (isLoading.value || isNoMore.value) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const page = await PointAPI.redeemSkuList({ index: index.value, size: 20 });
|
||||
const list = page.list || [];
|
||||
result.list = result.list.concat(list);
|
||||
result.total = page.total || 0;
|
||||
index.value++;
|
||||
isNoMore.value = result.total <= result.list.length || list.length < 20;
|
||||
} catch {
|
||||
Message.error("兑换配置加载失败");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoint(value?: number): string {
|
||||
return `${(value || 0).toLocaleString("zh-CN")} 分`;
|
||||
}
|
||||
|
||||
function statusText(status?: PointRedeemSkuStatus): string {
|
||||
return ({
|
||||
DRAFT: "草稿",
|
||||
ACTIVE: "启用",
|
||||
INACTIVE: "停用",
|
||||
DELETED: "已删除"
|
||||
} as Record<string, string>)[status || ""] || "未知";
|
||||
}
|
||||
|
||||
async function toCreate(): Promise<void> {
|
||||
await router.push({ name: "PointRedeemSkuCreate" });
|
||||
}
|
||||
|
||||
async function toEdit(item: PointRedeemSku): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push({ name: "PointRedeemSkuEdit", params: { id: item.id } });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setCustomRightVisible(true);
|
||||
await reload();
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redeem-sku-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 5rem;
|
||||
|
||||
.title {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tips {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedemption.create" class="redemption-create">
|
||||
<t-form class="form" label-width="7rem" v-entrance-transition>
|
||||
<t-form-item label="客户" required-mark>
|
||||
<t-input
|
||||
:model-value="customerName || '请选择客户'"
|
||||
class="customer"
|
||||
readonly
|
||||
borderless
|
||||
placeholder="选择或扫码客户"
|
||||
@click="customerVisible = true"
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
size="extra-small"
|
||||
@click.stop="customerVisible = true"
|
||||
>选择</t-button>
|
||||
<t-button theme="primary" size="extra-small" @click.stop="toggleScanner">
|
||||
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-input>
|
||||
</t-form-item>
|
||||
<div v-if="scannerVisible" class="code-scanner-wrapper">
|
||||
<code-scanner
|
||||
visible
|
||||
:status-text="scanStatus"
|
||||
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</div>
|
||||
<customer-profile-card
|
||||
v-if="customer"
|
||||
class="customer-profile"
|
||||
:customer="customer"
|
||||
v-entrance-transition
|
||||
/>
|
||||
<t-form-item v-if="customer" label="积分余额" content-align="right">
|
||||
<span v-text="pointBalanceText" />
|
||||
</t-form-item>
|
||||
<t-form-item label="兑换商品" required-mark content-align="right" arrow @click="skuVisible = true">
|
||||
<span v-text="skuName || '请选择兑换商品'" />
|
||||
</t-form-item>
|
||||
<t-form-item label="兑换数量" content-align="right" required-mark>
|
||||
<t-stepper
|
||||
v-model="quantity"
|
||||
size="large"
|
||||
:min="quantityStep"
|
||||
:step="quantityStep"
|
||||
:integer="quantityIsInteger"
|
||||
theme="outline"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="预计消耗积分" content-align="right">
|
||||
<span v-text="estimatedPointText" />
|
||||
</t-form-item>
|
||||
<template v-if="canAdjust">
|
||||
<t-form-item label="实际消耗扣分">
|
||||
<t-input v-model.trim="actualPointText" type="number" align="right" borderless placeholder="默认扣分" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model="remark" maxlength="500" autosize />
|
||||
</t-form-item>
|
||||
</template>
|
||||
<div class="photo-wrap">
|
||||
<photo-scroller
|
||||
v-model="photoUrls"
|
||||
v-model:photo-items="photos"
|
||||
:upload="uploadPhotoList"
|
||||
edit-mode
|
||||
required
|
||||
title="兑换凭证照片"
|
||||
/>
|
||||
</div>
|
||||
</t-form>
|
||||
</div>
|
||||
<customer-selector
|
||||
v-model="customerId"
|
||||
v-model:visible="customerVisible"
|
||||
:enable-scan="false"
|
||||
@change="loadCustomer"
|
||||
/>
|
||||
<bottom-popup
|
||||
v-model="skuVisible"
|
||||
title="选择兑换商品"
|
||||
height="70vh"
|
||||
right-button-text=""
|
||||
@left-button-click="closeSkuPicker"
|
||||
>
|
||||
<div class="sku-picker">
|
||||
<t-cell
|
||||
v-for="item in skuList"
|
||||
:key="item.id"
|
||||
:title="item.product?.name || item.sku?.name || '未命名产品'"
|
||||
:note="`${item.sku?.specSummary || item.sku?.code || '默认 SKU'} · ${item.unit?.name || ''} · ${formatPoint(item.defaultPointCost)} / 单位`"
|
||||
arrow
|
||||
@click="selectSku(item)"
|
||||
/>
|
||||
<t-empty v-if="!skuList.length" description="暂无启用的兑换商品" />
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import type { PointAccount, PointRedeemSku } from "@/types/Point";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { normalizePackageQuantity } from "@/utils/package";
|
||||
import { getProductQuantityStep } from "@/utils/product";
|
||||
import { uploadPhotoList } from "@/utils/photo";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedemptionCreate" });
|
||||
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const settingStore = useSettingStore();
|
||||
const userStore = useUserStore();
|
||||
const customerId = ref("");
|
||||
const customer = ref<Customer>();
|
||||
const pointAccount = ref<PointAccount>();
|
||||
const pointAccountLoading = ref(false);
|
||||
const customerVisible = ref(false);
|
||||
const skuVisible = ref(false);
|
||||
const skuList = ref<PointRedeemSku[]>([]);
|
||||
const selectedSku = ref<PointRedeemSku>();
|
||||
const quantity = ref("1");
|
||||
const actualPointText = ref("");
|
||||
const remark = ref("");
|
||||
const photos = ref<PhotoItem[]>([]);
|
||||
const requestNo = ref("");
|
||||
const saving = ref(false);
|
||||
const canReadPointAccount = computed(() => userStore.hasPermissions(permissionCodes.point.accountRead));
|
||||
const canAdjust = computed(() => userStore.hasPermissions(permissionCodes.pointRedemption.pointAdjust));
|
||||
const photoUrls = computed(() => photos.value.map(item => item.url));
|
||||
const customerName = computed(() => customer.value?.name);
|
||||
const skuName = computed(() => selectedSku.value ? `${selectedSku.value.product?.name || selectedSku.value.sku?.name || "未命名产品"} · ${selectedSku.value.unit?.name || ""}` : "");
|
||||
const quantityStep = computed(() => getProductQuantityStep(selectedSku.value?.sku));
|
||||
const quantityIsInteger = computed(() => !selectedSku.value?.sku?.allowDecimalQuantity);
|
||||
const estimatedPointText = computed(() => {
|
||||
const unitPoint = selectedSku.value?.defaultPointCost || 0;
|
||||
const count = Number(quantity.value);
|
||||
return Number.isFinite(count) && 0 < count ? formatPoint(Math.round(unitPoint * count)) : "填写数量后计算";
|
||||
});
|
||||
const pointBalanceText = computed(() => {
|
||||
if (!canReadPointAccount.value) {
|
||||
return "无余额读取权限";
|
||||
}
|
||||
return pointAccountLoading.value ? "正在加载" : `${(pointAccount.value?.balance ?? 0).toLocaleString("zh-CN")} 分`;
|
||||
});
|
||||
const scannerVisible = ref(false);
|
||||
const scanStatus = ref("点击扫码开始识别。");
|
||||
let latestDecodedText = "";
|
||||
|
||||
|
||||
async function loadCustomer(value: string | string[]): Promise<void> {
|
||||
const id = Array.isArray(value) ? value[0] : value;
|
||||
if (!id) {
|
||||
customer.value = undefined;
|
||||
pointAccount.value = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
customer.value = await CustomerAPI.detail(id);
|
||||
if (canReadPointAccount.value) {
|
||||
await loadPointAccount(id, customer.value.storeId);
|
||||
}
|
||||
} catch {
|
||||
customer.value = undefined;
|
||||
pointAccount.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPointAccount(customerIdValue: string, storeId?: string): Promise<void> {
|
||||
pointAccountLoading.value = true;
|
||||
try {
|
||||
pointAccount.value = await PointAPI.detailByCustomerId(customerIdValue, storeId);
|
||||
} catch {
|
||||
pointAccount.value = undefined;
|
||||
} finally {
|
||||
pointAccountLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScanner(): void {
|
||||
scannerVisible.value = !scannerVisible.value;
|
||||
if (scannerVisible.value) {
|
||||
latestDecodedText = "";
|
||||
scanStatus.value = "点击扫码开始识别。";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecodedText(rawText: string): Promise<void> {
|
||||
const normalizedText = rawText.trim();
|
||||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||
return;
|
||||
}
|
||||
latestDecodedText = normalizedText;
|
||||
await settingStore.playScanSuccessSound();
|
||||
await applyScanCode(normalizedText);
|
||||
}
|
||||
|
||||
async function applyScanCode(code: string): Promise<void> {
|
||||
scanStatus.value = `已识别 ${code},正在加载客户档案。`;
|
||||
try {
|
||||
const result = await CustomerAPI.findByCode(code);
|
||||
if (!result?.id) {
|
||||
customer.value = undefined;
|
||||
customerId.value = "";
|
||||
scanStatus.value = `客户 ${code} 不存在,请先建档。`;
|
||||
return;
|
||||
}
|
||||
customer.value = result;
|
||||
customerId.value = result.id;
|
||||
if (canReadPointAccount.value) {
|
||||
await loadPointAccount(result.id, result.storeId);
|
||||
}
|
||||
scanStatus.value = `已识别 ${result.code || code},已自动选中 ${result.name || "未命名客户"}。`;
|
||||
scannerVisible.value = false;
|
||||
} catch (error) {
|
||||
scanStatus.value = getErrorMessage(error, "客户查询失败,请重试。");
|
||||
}
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scanStatus.value = value;
|
||||
}
|
||||
|
||||
async function loadSkuList(): Promise<void> {
|
||||
const page = await PointAPI.redeemSkuList({ index: 0, size: 1000, status: "ACTIVE" });
|
||||
skuList.value = page.list || [];
|
||||
}
|
||||
|
||||
function selectSku(item: PointRedeemSku): void {
|
||||
selectedSku.value = item;
|
||||
skuVisible.value = false;
|
||||
}
|
||||
|
||||
function closeSkuPicker(): void {
|
||||
skuVisible.value = false;
|
||||
}
|
||||
|
||||
function attachments(): Array<{ tempFileId: string; attachType: "PHOTO"; name?: string }> {
|
||||
return photos.value.flatMap(item => item.tempFileId ? [{
|
||||
tempFileId: item.tempFileId,
|
||||
attachType: "PHOTO" as const
|
||||
}] : []);
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const normalized = normalizePackageQuantity(quantity.value);
|
||||
const actual = actualPointText.value ? Number(actualPointText.value) : undefined;
|
||||
if (
|
||||
saving.value
|
||||
|| !customerId.value
|
||||
|| !selectedSku.value?.id
|
||||
|| !normalized
|
||||
|| !attachments().length
|
||||
|| (actual !== undefined && (!canAdjust.value || !Number.isSafeInteger(actual) || actual < 0))
|
||||
) {
|
||||
Message.warning("请填写客户、商品、正数数量并上传兑换照片");
|
||||
return;
|
||||
}
|
||||
if (!requestNo.value) {
|
||||
requestNo.value = window.crypto.randomUUID();
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await PointAPI.redemptionCreate({
|
||||
requestNo: requestNo.value,
|
||||
customerId: customerId.value,
|
||||
redeemSkuId: selectedSku.value.id,
|
||||
quantity: normalized,
|
||||
actualPointCost: actual,
|
||||
attachmentList: attachments(),
|
||||
remark: remark.value || null
|
||||
});
|
||||
Message.success("兑换已完成");
|
||||
await router.replace({ name: "PointRedemptionDetail", params: { id: result.id } });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换失败,重试将复用同一请求号"));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoint(value?: number): string {
|
||||
return `${(value || 0).toLocaleString("zh-CN")} 分`;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setRightText("兑换", "var(--td-brand-color)", save);
|
||||
try {
|
||||
await loadSkuList();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换商品加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redemption-create {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
|
||||
.form {
|
||||
margin: 0 1rem;
|
||||
padding: .5rem 0;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.4rem;
|
||||
|
||||
.customer {
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.code-scanner-wrapper,
|
||||
.photo-wrap {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sku-picker {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.redemption-create {
|
||||
.form {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedemption.create" class="redemption-detail">
|
||||
<customer-profile-card
|
||||
v-if="customer"
|
||||
class="customer-profile"
|
||||
:customer="customer"
|
||||
v-entrance-transition
|
||||
/>
|
||||
<t-cell-group v-if="item" title="兑换信息" theme="card">
|
||||
<t-cell title="客户" :note="item.customer?.name || item.customerId || '-'" />
|
||||
<t-cell title="兑换商品" :note="item.productNameSnapshot || item.skuNameSnapshot || '-'" />
|
||||
<t-cell title="兑换数量" :note="`${quantityText(item.quantity)} ${item.unitSnapshot || ''}`" />
|
||||
<t-cell title="积分" :note="`${formatPoint(item.actualPointCost)}${item.actualPointCost !== item.defaultPointCost ? '(已减免)' : ''}`" />
|
||||
<t-cell title="状态" :note="statusText(item.status)" />
|
||||
<t-cell title="兑换时间" :note="timeText(item.redeemedAt)" />
|
||||
<t-cell v-if="item.remark" title="备注" :note="item.remark" />
|
||||
<div class="photo-wrap bg-white">
|
||||
<photo-scroller
|
||||
:model-value="attachmentUrls"
|
||||
:show-add="false"
|
||||
:show-remove="false"
|
||||
:show-viewer-actions="false"
|
||||
title="兑换凭证照片"
|
||||
/>
|
||||
</div>
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="returnList.length" title="退货记录" theme="card">
|
||||
<t-cell
|
||||
v-for="record in returnList"
|
||||
:key="record.id"
|
||||
:title="`退货 ${quantityText(record.quantity)} ${item?.unitSnapshot || ''}`"
|
||||
:note="`${formatPoint(record.pointCost)} · ${timeText(record.returnedAt)}`"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<t-empty v-if="!item" :description="loading ? '正在加载兑换单' : '兑换单不存在'" />
|
||||
<t-button v-if="canReturn" class="return-button cell-button" theme="danger" @click="returnVisible = true">
|
||||
冲正兑换
|
||||
</t-button>
|
||||
</div>
|
||||
<bottom-popup
|
||||
v-model="returnVisible"
|
||||
title="冲正兑换"
|
||||
left-button-text="取消"
|
||||
:right-button-text="returning ? '提交中' : '确认冲正'"
|
||||
@left-button-click="returnVisible = false"
|
||||
@right-button-click="submitReturn"
|
||||
>
|
||||
<div class="return-form">
|
||||
<t-form class="form" label-width="6rem">
|
||||
<t-form-item label="剩余数量">
|
||||
<span v-text="`${remainText} ${item?.unitSnapshot || ''}`" />
|
||||
</t-form-item>
|
||||
<t-form-item label="冲正数量" required-mark>
|
||||
<t-input v-model.trim="returnQuantity" type="number" align="right" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="冲正原因">
|
||||
<t-textarea v-model.trim="returnRemark" maxlength="500" autosize />
|
||||
</t-form-item>
|
||||
<t-form-item label-width="0">
|
||||
<photo-scroller
|
||||
v-model="returnPhotoUrls"
|
||||
v-model:photo-items="returnPhotos"
|
||||
:upload="uploadPhotoList"
|
||||
edit-mode
|
||||
required
|
||||
title="冲正凭证照片"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
import { AttachmentAPI, useUserStore } from "timi-web-api";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import type { PointRedemption, PointRedemptionReturn, PointRedemptionStatus } from "@/types/Point";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { comparePackageQuantity, getPackageQuantityText, normalizePackageQuantity } from "@/utils/package";
|
||||
import { uploadPhotoList } from "@/utils/photo";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedemptionDetail" });
|
||||
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const item = ref<PointRedemption>();
|
||||
const customer = ref<Customer>();
|
||||
const returnList = ref<PointRedemptionReturn[]>([]);
|
||||
const loading = ref(false);
|
||||
const returnVisible = ref(false);
|
||||
const returnQuantity = ref("");
|
||||
const returnRemark = ref("");
|
||||
const returnPhotos = ref<PhotoItem[]>([]);
|
||||
const returnRequestNo = ref("");
|
||||
const returning = ref(false);
|
||||
const attachmentUrls = computed(() => (item.value?.attachmentList || [])
|
||||
.filter(attachment => !!attachment.id)
|
||||
.map(attachment => AttachmentAPI.getReadURL(attachment.id!)));
|
||||
const returnPhotoUrls = computed(() => returnPhotos.value.map(photo => photo.url));
|
||||
const canReturn = computed(() => userStore.hasPermissions(permissionCodes.pointRedemption.return)
|
||||
&& (item.value?.status === "COMPLETED" || item.value?.status === "PARTIALLY_RETURNED")
|
||||
&& 0 < comparePackageQuantity(remaining(), "0"));
|
||||
const remainText = computed(() => quantityText(remaining()));
|
||||
|
||||
function remaining(): string {
|
||||
const total = Number(item.value?.quantity || 0);
|
||||
const returned = Number(item.value?.returnedQuantity || 0);
|
||||
return String(Math.max(0, total - returned));
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
item.value = await PointAPI.redemptionDetail(id);
|
||||
customer.value = item.value.customer;
|
||||
if (!customer.value && item.value.customerId) {
|
||||
try {
|
||||
customer.value = await CustomerAPI.detail(item.value.customerId);
|
||||
} catch {
|
||||
customer.value = undefined;
|
||||
}
|
||||
}
|
||||
returnList.value = await PointAPI.redemptionReturnList(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换单加载失败"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function attachments(): Array<{ tempFileId: string; attachType: "PHOTO"; name?: string }> {
|
||||
return returnPhotos.value.flatMap(photo => photo.tempFileId ? [{
|
||||
tempFileId: photo.tempFileId,
|
||||
attachType: "PHOTO" as const
|
||||
}] : []);
|
||||
}
|
||||
|
||||
async function submitReturn(): Promise<void> {
|
||||
const quantity = normalizePackageQuantity(returnQuantity.value);
|
||||
const redemptionId = item.value?.id;
|
||||
if (
|
||||
returning.value
|
||||
|| !redemptionId
|
||||
|| !quantity
|
||||
|| 0 < comparePackageQuantity(quantity, remaining())
|
||||
|| !attachments().length
|
||||
) {
|
||||
Message.warning("请填写不超过剩余数量的退货数量并上传照片");
|
||||
return;
|
||||
}
|
||||
if (!returnRequestNo.value) {
|
||||
returnRequestNo.value = window.crypto.randomUUID();
|
||||
}
|
||||
returning.value = true;
|
||||
try {
|
||||
item.value = await PointAPI.redemptionReturn({
|
||||
redemptionId,
|
||||
requestNo: returnRequestNo.value,
|
||||
quantity,
|
||||
attachmentList: attachments(),
|
||||
remark: returnRemark.value || null
|
||||
});
|
||||
customer.value = item.value.customer || customer.value;
|
||||
returnList.value = await PointAPI.redemptionReturnList(redemptionId);
|
||||
returnVisible.value = false;
|
||||
returnQuantity.value = "";
|
||||
returnRemark.value = "";
|
||||
returnPhotos.value = [];
|
||||
returnRequestNo.value = "";
|
||||
Message.success("退货已完成");
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "退货失败,重试将复用同一请求号"));
|
||||
} finally {
|
||||
returning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoint(value?: number): string {
|
||||
return `${(value || 0).toLocaleString("zh-CN")} 分`;
|
||||
}
|
||||
|
||||
function quantityText(value?: number | string): string {
|
||||
return getPackageQuantityText(value);
|
||||
}
|
||||
|
||||
function statusText(value?: PointRedemptionStatus): string {
|
||||
return ({
|
||||
COMPLETED: "已兑换",
|
||||
PARTIALLY_RETURNED: "部分退货",
|
||||
FULLY_RETURNED: "已退完"
|
||||
} as Record<string, string>)[value || ""] || "未知";
|
||||
}
|
||||
|
||||
function timeText(value?: number): string {
|
||||
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "-";
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redemption-detail {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
flex-direction: column;
|
||||
|
||||
.return-button {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.photo-wrap {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.return-form {
|
||||
padding-bottom: 1rem;
|
||||
|
||||
.form {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedemption.create" class="redemption-list">
|
||||
<div class="filters">
|
||||
<t-input
|
||||
v-model.trim="keyword"
|
||||
class="keyword"
|
||||
clearable
|
||||
borderless
|
||||
placeholder="搜索客户、产品或 SKU"
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
v-if="customerId"
|
||||
shape="circle"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="extra-small"
|
||||
aria-label="清除客户选择"
|
||||
@click.stop="clearCustomer"
|
||||
>
|
||||
<t-icon name="close" size="1rem" />
|
||||
</t-button>
|
||||
<t-button
|
||||
size="extra-small"
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
content="选择客户"
|
||||
@click.stop="customerSelectorVisible = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</t-input>
|
||||
<t-dropdown-menu class="menus">
|
||||
<t-dropdown-item
|
||||
v-model="dateFilterType"
|
||||
:label="dateFilterLabel"
|
||||
:options="dateOptions"
|
||||
@change="selectDateFilter"
|
||||
/>
|
||||
</t-dropdown-menu>
|
||||
</div>
|
||||
<t-pull-down-refresh
|
||||
v-model="refreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="reload"
|
||||
@scrolltolower="load"
|
||||
>
|
||||
<div class="list-wrap">
|
||||
<t-cell-group v-if="isCustomDate" class="date-summary" theme="card">
|
||||
<t-cell
|
||||
title="开始时间"
|
||||
:note="dateRange[0] || '-'"
|
||||
arrow
|
||||
@click="openStartTime"
|
||||
/>
|
||||
<t-cell
|
||||
title="结束时间"
|
||||
:note="dateRange[1] || '-'"
|
||||
arrow
|
||||
@click="openEndTime"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<div v-if="result.list.length" class="items">
|
||||
<t-cell-group theme="card">
|
||||
<t-cell
|
||||
v-for="(item, index) in result.list"
|
||||
:key="item.id"
|
||||
class="item"
|
||||
arrow
|
||||
v-entrance-transition="index * 30"
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<template #left-icon>
|
||||
<t-image
|
||||
v-if="getCustomerPhoto(item)"
|
||||
:src="getCustomerPhoto(item)"
|
||||
:alt="item.customer?.name || '客户照片'"
|
||||
fit="cover"
|
||||
class="avatar"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="avatar fallback"
|
||||
v-text="(item.customer?.name || item.customer?.code || '客').slice(0, 1)"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="title">
|
||||
<strong v-text="item.productNameSnapshot || item.skuNameSnapshot || '未命名产品'" />
|
||||
<t-tag
|
||||
size="small"
|
||||
:theme="item.status === 'FULLY_RETURNED' ? 'default' : 'success'"
|
||||
:content="statusText(item.status)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<span v-text="`${item.customer?.name || item.customerId || '未知客户'} · ${quantityText(item.quantity)} ${item.unitSnapshot || ''} · 实扣 ${formatPoint(item.actualPointCost)}`" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
</div>
|
||||
<t-empty
|
||||
v-else
|
||||
:description="loading ? '正在加载兑换记录' : hasFilter ? '未找到匹配的兑换记录' : '暂无兑换记录'"
|
||||
/>
|
||||
<div class="tips" v-text="`共 ${result.total} 条兑换记录`" />
|
||||
</div>
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
<customer-selector
|
||||
v-model="customerId"
|
||||
v-model:visible="customerSelectorVisible"
|
||||
@change="handleCustomerChange"
|
||||
/>
|
||||
<nav-bar-action-menu
|
||||
v-can="permissionCodes.pointRedemption.create"
|
||||
:actions="[{ key: 'create', icon: 'add', text: '客户兑换', theme: 'primary', action: toCreate }]"
|
||||
/>
|
||||
<t-popup v-model="startVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
v-if="startVisible"
|
||||
v-model="startDraft"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:mode="['date', 'minute']"
|
||||
title="开始时间"
|
||||
:default-value="dateRange[0]"
|
||||
@cancel="cancelStartTime"
|
||||
@confirm="confirmStartTime"
|
||||
/>
|
||||
</t-popup>
|
||||
<t-popup v-model="endVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
v-if="endVisible"
|
||||
v-model="endDraft"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:mode="['date', 'minute']"
|
||||
title="结束时间"
|
||||
:default-value="dateRange[1]"
|
||||
@cancel="cancelEndTime"
|
||||
@confirm="confirmEndTime"
|
||||
/>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { PointRedemption, PointRedemptionStatus } from "@/types/Point";
|
||||
import { Message } from "@/utils/message";
|
||||
import { getPackageQuantityText } from "@/utils/package";
|
||||
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedemptionList" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const result = reactive({ total: 0, list: [] as PointRedemption[] });
|
||||
type DateFilterType = "today" | "yesterday" | "lastWeek" | "thisMonth" | "lastMonth" | "all" | "custom";
|
||||
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
|
||||
{ label: "今天", value: "today" },
|
||||
{ label: "昨天", value: "yesterday" },
|
||||
{ label: "上周", value: "lastWeek" },
|
||||
{ label: "本月", value: "thisMonth" },
|
||||
{ label: "上个月", value: "lastMonth" },
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "自定义范围", value: "custom" }
|
||||
];
|
||||
const keyword = ref(getQueryText(route.query.keyword));
|
||||
const customerId = ref(getQueryText(route.query.customerId));
|
||||
const selectedCustomer = ref<Customer>();
|
||||
const customerSelectorVisible = ref(false);
|
||||
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
|
||||
const dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value));
|
||||
const startDraft = ref("");
|
||||
const endDraft = ref("");
|
||||
const startVisible = ref(false);
|
||||
const endVisible = ref(false);
|
||||
const index = ref(0);
|
||||
const loading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const noMore = ref(false);
|
||||
let keywordTimer: number | undefined;
|
||||
let reloadRequested = false;
|
||||
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "时间范围");
|
||||
const isCustomDate = computed(() => dateFilterType.value === "custom");
|
||||
const hasFilter = computed(() => Boolean(keyword.value.trim() || customerId.value || dateFilterType.value !== "all"));
|
||||
|
||||
function getQueryText(value: unknown): string {
|
||||
return Array.isArray(value) ? "" : typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseDateFilterType(value: unknown): DateFilterType {
|
||||
const dateFilter = getQueryText(value);
|
||||
return dateOptions.some(item => item.value === dateFilter) ? dateFilter as DateFilterType : "today";
|
||||
}
|
||||
|
||||
function parseDateRange(type: DateFilterType): [string, string] {
|
||||
if (type !== "custom") {
|
||||
return buildPresetRange(type);
|
||||
}
|
||||
return [getQueryText(route.query.beginAt), getQueryText(route.query.endAt)];
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
if (loading.value) {
|
||||
reloadRequested = true;
|
||||
return;
|
||||
}
|
||||
index.value = 0;
|
||||
result.total = 0;
|
||||
result.list = [];
|
||||
noMore.value = false;
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const [beginAt, endAt] = buildUnixRange();
|
||||
const page = await PointAPI.redemptionList({
|
||||
index: index.value,
|
||||
size: 20,
|
||||
customerId: customerId.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
beginAt,
|
||||
endAt
|
||||
});
|
||||
const list = page.list || [];
|
||||
result.list = result.list.concat(list);
|
||||
result.total = page.total || 0;
|
||||
index.value++;
|
||||
noMore.value = result.total <= result.list.length || list.length < 20;
|
||||
} catch {
|
||||
Message.error("兑换记录加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (reloadRequested) {
|
||||
reloadRequested = false;
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoint(value?: number): string {
|
||||
return `${(value || 0).toLocaleString("zh-CN")} 分`;
|
||||
}
|
||||
|
||||
function quantityText(value?: number | string): string {
|
||||
return getPackageQuantityText(value);
|
||||
}
|
||||
|
||||
function getCustomerPhoto(item: PointRedemption): string {
|
||||
return getCustomerPhotoURL(item.customer, 64);
|
||||
}
|
||||
|
||||
async function handleCustomerChange(value: string | string[]): Promise<void> {
|
||||
customerId.value = Array.isArray(value) ? String(value[0] || "") : value;
|
||||
try {
|
||||
selectedCustomer.value = customerId.value ? await CustomerAPI.detail(customerId.value) : undefined;
|
||||
await reload();
|
||||
} catch {
|
||||
selectedCustomer.value = undefined;
|
||||
Message.error("客户信息加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCustomer(): Promise<void> {
|
||||
customerId.value = "";
|
||||
selectedCustomer.value = undefined;
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function selectDateFilter(value: DropdownValue): Promise<void> {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
dateFilterType.value = String(value) as DateFilterType;
|
||||
if (dateFilterType.value !== "custom") {
|
||||
dateRange.value = buildPresetRange(dateFilterType.value);
|
||||
await replaceFilterQuery();
|
||||
await reload();
|
||||
} else {
|
||||
await replaceFilterQuery();
|
||||
}
|
||||
}
|
||||
|
||||
function openStartTime(): void {
|
||||
startDraft.value = dateRange.value[0];
|
||||
startVisible.value = true;
|
||||
}
|
||||
|
||||
function openEndTime(): void {
|
||||
endDraft.value = dateRange.value[1];
|
||||
endVisible.value = true;
|
||||
}
|
||||
|
||||
function cancelStartTime(): void {
|
||||
startVisible.value = false;
|
||||
startDraft.value = dateRange.value[0];
|
||||
}
|
||||
|
||||
function cancelEndTime(): void {
|
||||
endVisible.value = false;
|
||||
endDraft.value = dateRange.value[1];
|
||||
}
|
||||
|
||||
async function confirmStartTime(value?: string | number): Promise<void> {
|
||||
startVisible.value = false;
|
||||
dateRange.value = [String(value || startDraft.value), dateRange.value[1]];
|
||||
await confirmCustomRange();
|
||||
}
|
||||
|
||||
async function confirmEndTime(value?: string | number): Promise<void> {
|
||||
endVisible.value = false;
|
||||
dateRange.value = [dateRange.value[0], String(value || endDraft.value)];
|
||||
await confirmCustomRange();
|
||||
}
|
||||
|
||||
async function confirmCustomRange(): Promise<void> {
|
||||
if (dateRange.value[0] && dateRange.value[1] && dateRange.value[1] < dateRange.value[0]) {
|
||||
dateRange.value = [dateRange.value[1], dateRange.value[0]];
|
||||
}
|
||||
dateFilterType.value = "custom";
|
||||
await replaceFilterQuery();
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function replaceFilterQuery(): Promise<void> {
|
||||
await router.replace({
|
||||
name: "PointRedemptionList",
|
||||
query: {
|
||||
customerId: customerId.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
dateType: dateFilterType.value,
|
||||
beginAt: dateRange.value[0] || undefined,
|
||||
endAt: dateRange.value[1] || undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
|
||||
if (type === "all") {
|
||||
return ["", ""];
|
||||
}
|
||||
if (type === "lastWeek") {
|
||||
const current = new Date();
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const day = current.getDay() || 7;
|
||||
const thisWeekMonday = new Date(current);
|
||||
thisWeekMonday.setDate(current.getDate() - day + 1);
|
||||
const lastWeekMonday = new Date(thisWeekMonday);
|
||||
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
|
||||
const lastWeekSunday = new Date(thisWeekMonday);
|
||||
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
|
||||
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
|
||||
}
|
||||
if (type === "thisMonth") {
|
||||
const current = new Date();
|
||||
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
|
||||
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
|
||||
}
|
||||
if (type === "lastMonth") {
|
||||
const current = new Date();
|
||||
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
|
||||
const end = new Date(current.getFullYear(), current.getMonth(), 0);
|
||||
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
|
||||
}
|
||||
|
||||
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
|
||||
today: 0,
|
||||
yesterday: 1
|
||||
};
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetMap[type]);
|
||||
const value = `${Time.formatDate(date)} 00:00`;
|
||||
return [value, `${Time.formatDate(date)} 23:59`];
|
||||
}
|
||||
|
||||
function buildUnixRange(): [number | undefined, number | undefined] {
|
||||
if (dateFilterType.value === "all") {
|
||||
return [0, Time.toTimestamp(new Date())];
|
||||
}
|
||||
return [
|
||||
Time.toTimestamp(parseDateTime(dateRange.value[0], false)),
|
||||
Time.toTimestamp(parseDateTime(dateRange.value[1], true))
|
||||
];
|
||||
}
|
||||
|
||||
function parseDateTime(value: string, isEnd: boolean): Date {
|
||||
if (!value) {
|
||||
return isEnd ? new Date() : new Date(0);
|
||||
}
|
||||
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
|
||||
const [year, month, day] = dateText.split("-").map(Number);
|
||||
const [hour, minute] = timeText.split(":").map(Number);
|
||||
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
|
||||
}
|
||||
|
||||
function statusText(value?: PointRedemptionStatus): string {
|
||||
return ({
|
||||
COMPLETED: "已兑换",
|
||||
PARTIALLY_RETURNED: "部分退货",
|
||||
FULLY_RETURNED: "已退完"
|
||||
} as Record<string, string>)[value || ""] || "未知";
|
||||
}
|
||||
|
||||
async function toCreate(): Promise<void> {
|
||||
await router.push({ name: "PointRedemptionCreate" });
|
||||
}
|
||||
|
||||
async function toDetail(item: PointRedemption): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push({ name: "PointRedemptionDetail", params: { id: item.id } });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setCustomRightVisible(true);
|
||||
try {
|
||||
if (customerId.value) {
|
||||
selectedCustomer.value = await CustomerAPI.detail(customerId.value);
|
||||
}
|
||||
await reload();
|
||||
} catch {
|
||||
Message.error("兑换记录加载失败");
|
||||
}
|
||||
});
|
||||
|
||||
watch(keyword, () => {
|
||||
window.clearTimeout(keywordTimer);
|
||||
keywordTimer = window.setTimeout(async () => {
|
||||
await reload();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(keywordTimer);
|
||||
navBarStore.clearRight();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redemption-list {
|
||||
padding: var(--app-nav-height) 0 1rem;
|
||||
|
||||
.filters {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
padding: .75rem 1rem;
|
||||
position: sticky;
|
||||
background: #f5f5f8;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
flex-direction: column;
|
||||
|
||||
.keyword {
|
||||
--td-input-vertical-padding: .25rem 1rem;
|
||||
|
||||
flex: 1;
|
||||
border: 1px solid var(--td-component-border);
|
||||
z-index: 2;
|
||||
background: var(--td-bg-color-container);
|
||||
padding-right: .3rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
.actions {
|
||||
--td-button-border-radius: .75rem;
|
||||
|
||||
gap: .25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.menus {
|
||||
--td-spacer-1: 6px;
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, .18);
|
||||
overflow: hidden;
|
||||
margin-top: -1rem;
|
||||
background: #fff;
|
||||
border-top: none;
|
||||
padding-top: .8rem;
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-wrap {
|
||||
padding-top: 1rem;
|
||||
|
||||
.date-summary {
|
||||
:deep(.t-cell__title) {
|
||||
flex: 0 0 4.5rem;
|
||||
}
|
||||
|
||||
:deep(.t-cell__note) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
border-radius: 1rem;
|
||||
|
||||
.title {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.redemption-list {
|
||||
.filters {
|
||||
background: #10161f;
|
||||
|
||||
.menus {
|
||||
background: #1d2733;
|
||||
}
|
||||
}
|
||||
|
||||
.fallback {
|
||||
color: #ccfbf1;
|
||||
background: #115e59;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -372,15 +372,18 @@ onUnmounted(() => {
|
||||
box-shadow: var(--td-shadow-1);
|
||||
|
||||
.keyword {
|
||||
--td-input-vertical-padding: 8px 14px;
|
||||
|
||||
flex: 1;
|
||||
border: 1px solid var(--td-component-border);
|
||||
z-index: 1;
|
||||
padding: .5rem 1rem;
|
||||
background: var(--td-bg-color-container);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
--td-spacer-1: 6px;
|
||||
|
||||
margin-left: -2rem;
|
||||
padding-left: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
@@ -136,6 +136,59 @@
|
||||
/>
|
||||
</div>
|
||||
</t-form-item>
|
||||
<t-form-item
|
||||
v-if="canReceiveImmediately"
|
||||
class="payment-item"
|
||||
:class="{ disabled: receiveFullPayment }"
|
||||
:label="receiveFullPayment ? '收款和核销金额' : '已部分收款和核销'"
|
||||
label-width="9rem"
|
||||
content-align="right"
|
||||
>
|
||||
<t-input
|
||||
:model-value="receiveFullPayment ? formatProductAmount(totalAmount || 0) : paymentAmountText"
|
||||
type="number"
|
||||
align="right"
|
||||
borderless
|
||||
:disabled="receiveFullPayment"
|
||||
:placeholder="paymentAmountPlaceholder"
|
||||
@update:model-value="paymentAmountText = String($event)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="已全额收款和核销" content-align="right">
|
||||
<t-switch
|
||||
v-model="receiveFullPayment"
|
||||
size="small"
|
||||
:disabled="!canReceiveImmediately"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="已全部产品履约和核销" content-align="right">
|
||||
<t-switch
|
||||
v-model="fulfillAll"
|
||||
size="small"
|
||||
:disabled="!canFulfillImmediately"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label-width="0">
|
||||
<div class="red">全部履约会同步启用全额收款;请先准备全部产品库存,任意一步失败,整张订单都会回滚。</div>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="(paymentVerified && paymentAmount) || (receiveFullPayment && totalAmount)" label-width="0">
|
||||
<photo-scroller
|
||||
:model-value="paymentPhotoUrlList"
|
||||
v-model:photo-items="paymentPhotoItems"
|
||||
:upload="uploadPhotoList"
|
||||
:edit-mode="true"
|
||||
title="收款凭证照片(必要)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="fulfillAll" label-width="0">
|
||||
<photo-scroller
|
||||
:model-value="fulfillmentPhotoUrlList"
|
||||
v-model:photo-items="fulfillmentPhotoItems"
|
||||
:upload="uploadPhotoList"
|
||||
:edit-mode="true"
|
||||
title="履约凭证照片(必要)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="订单备注">
|
||||
<t-textarea v-model="remark" class="textarea" borderless autosize maxlength="500" placeholder="记录本单需要注意的内容" />
|
||||
</t-form-item>
|
||||
@@ -169,6 +222,7 @@
|
||||
<script setup lang="ts">
|
||||
import { type Attachment, useUserStore } from "timi-web-api";
|
||||
import { Time } from "timi-web";
|
||||
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import ProductAPI from "@/api/ProductAPI";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
@@ -206,7 +260,18 @@ const draftTime = ref("");
|
||||
const pickerVisible = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const paymentPhotoItems = ref<PhotoItem[]>([]);
|
||||
const fulfillmentPhotoItems = ref<PhotoItem[]>([]);
|
||||
const paymentAmountText = ref("0");
|
||||
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
|
||||
const paymentPhotoUrlList = computed(() => paymentPhotoItems.value.map(item => item.url));
|
||||
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
|
||||
const receiveFullPayment = ref(false);
|
||||
const fulfillAll = ref(false);
|
||||
const paymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
|
||||
const paymentVerified = computed(() => !receiveFullPayment.value
|
||||
&& 0 < paymentAmountText.value.trim().length
|
||||
&& paymentAmount.value !== 0);
|
||||
const maxQuantity = Number.MAX_SAFE_INTEGER;
|
||||
const productCache = new Map<string, Product>();
|
||||
const skuCache = new Map<string, ProductSku>();
|
||||
@@ -218,6 +283,26 @@ const scannerVisible = ref(false);
|
||||
const scanStatus = ref("点击扫码开始识别。");
|
||||
let latestDecodedText = "";
|
||||
|
||||
// ---------- 交易直通 ----------
|
||||
|
||||
const canReceiveImmediately = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.create)
|
||||
&& userStore.hasPermissions(permissionCodes.fundReceive.verify));
|
||||
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
|
||||
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
|
||||
&& (totalAmount.value === 0 || canReceiveImmediately.value));
|
||||
|
||||
watch(fulfillAll, value => {
|
||||
if (value && totalAmount.value !== 0) {
|
||||
receiveFullPayment.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
watch(receiveFullPayment, value => {
|
||||
if (!value && totalAmount.value !== 0 && (!paymentVerified.value || paymentAmount.value !== totalAmount.value)) {
|
||||
fulfillAll.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function getSku(item: FormItem): ProductSku | undefined {
|
||||
return skuCache.get(item.skuId || "");
|
||||
}
|
||||
@@ -253,6 +338,7 @@ const totalAmount = computed(() => {
|
||||
return amount;
|
||||
});
|
||||
const totalAmountText = computed(() => totalAmount.value === undefined ? "0.00" : formatProductAmount(totalAmount.value));
|
||||
const paymentAmountPlaceholder = computed(() => `订单总金额:${formatProductAmount(totalAmount.value || 0)}`);
|
||||
|
||||
function createItem(): FormItem {
|
||||
return {
|
||||
@@ -376,8 +462,8 @@ function resolveTimestamp(value: string): number | undefined {
|
||||
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
function buildAttachments(): Partial<Attachment>[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
|
||||
function buildAttachments(source: PhotoItem[] = photoItems.value): Partial<Attachment>[] {
|
||||
return source.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
|
||||
}
|
||||
function validate(): boolean {
|
||||
if (!customer.value?.id) { Message.warning("请选择客户"); return false; }
|
||||
@@ -392,10 +478,40 @@ function validate(): boolean {
|
||||
keys.add(key);
|
||||
if (canPriceOverride.value && item.unitPriceText && parsePackageAmount(item.unitPriceText) === undefined) { Message.warning("成交单价必须是合法金额"); return false; }
|
||||
}
|
||||
if ((paymentVerified.value || receiveFullPayment.value) && !canReceiveImmediately.value) { Message.warning("当前账号没有收款登记和核销权限"); return false; }
|
||||
if (paymentVerified.value && (paymentAmount.value === undefined || paymentAmount.value <= 0 || paymentAmount.value > (totalAmount.value || 0))) { Message.warning("已核销收款金额必须大于 0 且不能超过订单总价"); return false; }
|
||||
if (fulfillAll.value && !canFulfillImmediately.value) { Message.warning("当前账号没有交易直通所需权限"); return false; }
|
||||
if (fulfillAll.value && totalAmount.value !== 0 && !paymentVerified.value && !receiveFullPayment.value) { Message.warning("全部履约直通必须同时完成收款核销"); return false; }
|
||||
if (fulfillAll.value && paymentVerified.value && paymentAmount.value !== totalAmount.value) { Message.warning("全部履约直通必须先完成全额收款核销"); return false; }
|
||||
if ((paymentVerified.value || receiveFullPayment.value) && totalAmount.value && !buildAttachments(paymentPhotoItems.value).length) { Message.warning("收款核销必须上传收款凭证照片"); return false; }
|
||||
if (fulfillAll.value && !buildAttachments(fulfillmentPhotoItems.value).length) { Message.warning("全部履约直通必须上传履约凭证照片"); return false; }
|
||||
return true;
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value || !validate()) return;
|
||||
if (paymentVerified.value || receiveFullPayment.value || fulfillAll.value) {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "确认直通完成交易",
|
||||
content: fulfillAll.value
|
||||
? "将一次性创建订单、完成收款核销,并完成全部履约和库存扣减。提交后如需更正,只能走撤销或冲正流程。"
|
||||
: paymentVerified.value
|
||||
? "将一次性创建订单并登记已核销收款。提交后如需更正,只能走资金冲正流程。"
|
||||
: "将一次性创建订单并核销全额收款。提交后如需更正,只能走资金冲正流程。",
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "确认并提交",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
await submitOrder();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
await submitOrder();
|
||||
}
|
||||
async function submitOrder(): Promise<void> {
|
||||
if (!requestNo.value) requestNo.value = createPackageRequestNo();
|
||||
isSaving.value = true;
|
||||
try {
|
||||
@@ -413,9 +529,17 @@ async function save(): Promise<void> {
|
||||
purchasedAt: resolveTimestamp(purchasedAtText.value),
|
||||
itemList,
|
||||
attachmentList: buildAttachments(),
|
||||
straightThrough: paymentVerified.value || receiveFullPayment.value || fulfillAll.value ? {
|
||||
paymentVerified: paymentVerified.value,
|
||||
paymentAmount: paymentAmount.value,
|
||||
receiveFullPayment: receiveFullPayment.value,
|
||||
paymentAttachmentList: buildAttachments(paymentPhotoItems.value),
|
||||
fulfillAll: fulfillAll.value,
|
||||
fulfillmentAttachmentList: buildAttachments(fulfillmentPhotoItems.value)
|
||||
} : undefined,
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("销售订单已创建");
|
||||
Message.success(fulfillAll.value ? "订单、收款及履约已完成" : paymentVerified.value ? "订单及已核销收款已完成" : receiveFullPayment.value ? "订单及全额收款已完成" : "销售订单已创建");
|
||||
await router.replace(`/management/sales-orders/${result.id}`);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "销售订单创建失败,重试会复用同一请求号");
|
||||
@@ -468,6 +592,14 @@ onUnmounted(() => navBarStore.clearRight());
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.payment-item.disabled {
|
||||
color: var(--td-text-color-disabled);
|
||||
|
||||
:deep(.t-form__label) {
|
||||
color: var(--td-text-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.product-part {
|
||||
|
||||
> .header {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<t-tag :content="getSalesOrderStatusText(order.status)" variant="light-outline" :theme="getStatusTheme(order.status)" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="no" v-text="order.orderNo" />
|
||||
<h1 class="no selectable" v-text="order.orderNo" />
|
||||
<div class="meta">
|
||||
<pass-time-label class="time" :timestamp="order.purchasedAt" underline />
|
||||
<div class="amount red text-bold">
|
||||
@@ -455,6 +455,7 @@ async function toFulfillmentList(): Promise<void> {
|
||||
path: "/management/order-fulfillments",
|
||||
query: {
|
||||
salesOrderId: order.value.id,
|
||||
dateType: "all",
|
||||
transitionDepth: String(getCurrentRouteDepth() + 1)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="sales-order-cell bg-white">
|
||||
<div class="sales-order-cell" :class="{ cancelled: isCancelled }">
|
||||
<div class="header">
|
||||
<div class="left">
|
||||
<t-image
|
||||
@@ -172,10 +172,11 @@ const photoURL = computed(() => getCustomerPhotoURL(props.order.customer, 64));
|
||||
const orderNoText = computed(() => `订单号:${props.order.orderNo || props.order.id || "未知"}`);
|
||||
const totalProductQuantity = computed(() => sumProductQuantity("quantity"));
|
||||
const productSummaryText = computed(() => `共 ${itemList.value.length} 项 · 订单数量 ${totalProductQuantity.value}`);
|
||||
const isCancelledPendingRefund = computed(() => props.order.status === "CANCELLED" && props.order.refundStatus === "PENDING");
|
||||
const isCancelled = computed(() => props.order.status === "CANCELLED");
|
||||
const isCancelledPendingRefund = computed(() => isCancelled.value && props.order.refundStatus === "PENDING");
|
||||
const showRefundActions = computed(() => props.canRefund && isCancelledPendingRefund.value);
|
||||
const showMoreActionMenu = computed(() => props.canCancel || showRefundActions.value);
|
||||
const showReceiveAction = computed(() => props.canReceive && props.order.status !== "CANCELLED" && props.order.paymentStatus !== "PAID");
|
||||
const showReceiveAction = computed(() => props.canReceive && !isCancelled.value && props.order.paymentStatus !== "PAID");
|
||||
const canCancelOrder = computed(() => props.order.status === "ACTIVE");
|
||||
const canFulfillByPayment = computed(() => props.order.paymentStatus === "PAID" || props.canStoreManager);
|
||||
const showFulfillAction = computed(() => props.canFulfill && canFulfillByPayment.value && props.order.status === "ACTIVE");
|
||||
@@ -241,8 +242,23 @@ function getProductText(item: NonNullable<SalesOrder["itemList"]>[number]): stri
|
||||
gap: .5rem;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
background: var(--td-bg-color-container);
|
||||
flex-direction: column;
|
||||
|
||||
&.cancelled {
|
||||
background: var(--td-gray-color-2);
|
||||
|
||||
.left,
|
||||
.product,
|
||||
.time-payment {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.customer {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 v-text="appName" />
|
||||
<p v-text="appDescription" />
|
||||
</section>
|
||||
<t-cell-group title="协议" theme="card" v-entrance-transition="70">
|
||||
<t-cell-group theme="card" v-entrance-transition="70">
|
||||
<t-cell
|
||||
v-for="item in agreementItemList"
|
||||
:key="item.code"
|
||||
@@ -21,6 +21,8 @@
|
||||
</t-cell-group>
|
||||
<footer class="footer" v-entrance-transition="140">
|
||||
<p>技术支持:夜雨</p>
|
||||
<p>联系技术:imyeyu@qq.com</p>
|
||||
<p>短信服务支持:广州易保网络科技有限公司</p>
|
||||
<p>Copyright © 2026 imyeyu.com</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -69,6 +71,7 @@ const agreementItemList: AgreementItem[] = [
|
||||
title: "开源软件协议"
|
||||
}
|
||||
];
|
||||
const agreementCodeList = agreementItemList.map(item => item.code);
|
||||
|
||||
const settingMap = ref<Record<string, Setting>>({});
|
||||
|
||||
@@ -92,7 +95,7 @@ async function openAgreement(item: AgreementItem): Promise<void> {
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
try {
|
||||
settingMap.value = await SettingAPI.map("GAO_SYSTEM");
|
||||
settingMap.value = await SettingAPI.map("GAO_SYSTEM", undefined, agreementCodeList);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "协议配置加载失败"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="help-page empty-cell-title-margin">
|
||||
<div class="search-wrap">
|
||||
<t-input
|
||||
v-model.trim="searchKeyword"
|
||||
class="search"
|
||||
type="search"
|
||||
borderless
|
||||
aria-label="搜索使用说明"
|
||||
placeholder="搜索使用说明"
|
||||
>
|
||||
<template #prefix-icon>
|
||||
<t-icon name="search" />
|
||||
</template>
|
||||
</t-input>
|
||||
</div>
|
||||
<t-cell-group v-if="visibleItemList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="(entry, index) in visibleItemList"
|
||||
:key="entry.path.join('-')"
|
||||
:title="entry.item.title"
|
||||
:description="entry.description"
|
||||
arrow
|
||||
v-entrance-transition="index * 30"
|
||||
@click="openItem(entry)"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-icon :name="getItemIcon(entry.item)" size="1.75rem" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-empty
|
||||
v-else
|
||||
:description="emptyDescription"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SettingAPI } from "timi-web-api";
|
||||
import { useUserStore } from "timi-web-api/store";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
|
||||
type HelpMenuItem = {
|
||||
children: HelpMenuItem[];
|
||||
contentId: string;
|
||||
description: string;
|
||||
type: HelpItemType;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type HelpItemType = "guide" | "qa";
|
||||
|
||||
type HelpMenuItemSource = {
|
||||
canReadRoleCodes?: unknown;
|
||||
children?: unknown;
|
||||
contentId?: unknown;
|
||||
description?: unknown;
|
||||
type?: unknown;
|
||||
title?: unknown;
|
||||
};
|
||||
|
||||
type HelpListEntry = {
|
||||
description: string;
|
||||
item: HelpMenuItem;
|
||||
path: number[];
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
name: "Help"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const helpMenu = shallowRef<HelpMenuItem[]>([]);
|
||||
const isLoading = shallowRef(false);
|
||||
const searchKeyword = shallowRef("");
|
||||
|
||||
const menuPath = computed(() => parseMenuPath(route.params.pathMatch));
|
||||
const currentItemList = computed(() => getMenuItems(helpMenu.value, menuPath.value));
|
||||
const isSearchMode = computed(() => !!normalizeSearchValue(searchKeyword.value));
|
||||
const visibleItemList = computed<HelpListEntry[]>(() => {
|
||||
if (!isSearchMode.value) {
|
||||
return currentItemList.value.map((item, index) => createListEntry(
|
||||
item,
|
||||
[...menuPath.value, index]
|
||||
));
|
||||
}
|
||||
|
||||
const keyword = normalizeSearchValue(searchKeyword.value);
|
||||
return getAllListEntries(helpMenu.value).filter(entry => [
|
||||
entry.item.title,
|
||||
entry.item.description,
|
||||
entry.description
|
||||
].some(value => normalizeSearchValue(value).includes(keyword)));
|
||||
});
|
||||
const emptyDescription = computed(() => {
|
||||
if (isLoading.value) {
|
||||
return "正在加载使用说明";
|
||||
}
|
||||
|
||||
return isSearchMode.value ? "未找到匹配的使用说明" : "暂无可用使用说明";
|
||||
});
|
||||
|
||||
function parseMenuPath(value: unknown): number[] {
|
||||
const segmentList = Array.isArray(value) ? value : value === undefined ? [] : [value];
|
||||
return segmentList
|
||||
.flatMap(segment => String(segment).split("/"))
|
||||
.map(segment => segment.trim())
|
||||
.filter(Boolean)
|
||||
.map(segment => Number(segment))
|
||||
.filter(index => Number.isInteger(index) && !(index < 0));
|
||||
}
|
||||
|
||||
function getMenuItems(itemList: HelpMenuItem[], path: number[]): HelpMenuItem[] {
|
||||
let currentItemList = itemList;
|
||||
for (const index of path) {
|
||||
const item = currentItemList[index];
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
currentItemList = item.children;
|
||||
}
|
||||
return currentItemList;
|
||||
}
|
||||
|
||||
function normalizeSearchValue(value?: string): string {
|
||||
return value?.trim().toLocaleLowerCase() || "";
|
||||
}
|
||||
|
||||
function createListEntry(item: HelpMenuItem, path: number[], parentTitleList: string[] = []): HelpListEntry {
|
||||
const description = parentTitleList.length
|
||||
? [parentTitleList.join(" > "), item.description].filter(Boolean).join(" · ")
|
||||
: item.description;
|
||||
return { description, item, path };
|
||||
}
|
||||
|
||||
function getAllListEntries(
|
||||
itemList: HelpMenuItem[],
|
||||
parentPath: number[] = [],
|
||||
parentTitleList: string[] = []
|
||||
): HelpListEntry[] {
|
||||
return itemList.flatMap((item, index) => {
|
||||
const path = [...parentPath, index];
|
||||
const entry = createListEntry(item, path, parentTitleList);
|
||||
return [
|
||||
entry,
|
||||
...getAllListEntries(item.children, path, [...parentTitleList, item.title])
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function parseStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasRoleAccess(roleCodes: string[]): boolean {
|
||||
const candidateRoleCodes = roleCodes.flatMap(roleCode => roleCode.includes(":")
|
||||
? [roleCode]
|
||||
: [roleCode, `GAO:${roleCode}`]);
|
||||
return userStore.hasRoles(candidateRoleCodes);
|
||||
}
|
||||
|
||||
function normalizeMenuItem(value: unknown): HelpMenuItem | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = value as HelpMenuItemSource;
|
||||
const title = typeof source.title === "string" ? source.title.trim() : "";
|
||||
if (!title) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const canReadRoleCodes = parseStringList(source.canReadRoleCodes);
|
||||
if (canReadRoleCodes.length && !hasRoleAccess(canReadRoleCodes)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const childSourceList = Array.isArray(source.children) ? source.children : [];
|
||||
const hasConfiguredChildren = childSourceList.length > 0;
|
||||
const children = hasConfiguredChildren
|
||||
? childSourceList
|
||||
.map(item => normalizeMenuItem(item))
|
||||
.filter((item): item is HelpMenuItem => !!item)
|
||||
: [];
|
||||
const contentId = typeof source.contentId === "string" ? source.contentId.trim() : "";
|
||||
const description = typeof source.description === "string" ? source.description.trim() : "";
|
||||
const type: HelpItemType = source.type === "qa" ? "qa" : "guide";
|
||||
if (hasConfiguredChildren && !children.length && !contentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
children,
|
||||
contentId,
|
||||
description,
|
||||
type,
|
||||
title
|
||||
};
|
||||
}
|
||||
|
||||
function getItemIcon(item: HelpMenuItem): string {
|
||||
if (item.type === "qa") {
|
||||
return "file-unknown";
|
||||
}
|
||||
|
||||
return item.children.length ? "layers" : "file-attachment";
|
||||
}
|
||||
|
||||
function parseHelpMenu(value?: string): HelpMenuItem[] {
|
||||
if (!value?.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceList: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(sourceList)) {
|
||||
return [];
|
||||
}
|
||||
return sourceList
|
||||
.map(item => normalizeMenuItem(item))
|
||||
.filter((item): item is HelpMenuItem => !!item);
|
||||
} catch {
|
||||
Message.error("使用说明配置格式错误");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getRouteDepth(path: number[]): number {
|
||||
return Number(route.meta.baseDepth ?? route.meta.depth ?? 0) + path.length + 1;
|
||||
}
|
||||
|
||||
async function openItem(entry: HelpListEntry): Promise<void> {
|
||||
const { item, path } = entry;
|
||||
if (item.children.length) {
|
||||
searchKeyword.value = "";
|
||||
await router.push({
|
||||
name: "HelpMenu",
|
||||
params: {
|
||||
pathMatch: path.map(String)
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.contentId) {
|
||||
Message.warning(`${item.title}暂未配置`);
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push({
|
||||
name: "HelpArticle",
|
||||
params: {
|
||||
id: item.contentId
|
||||
},
|
||||
query: {
|
||||
transitionDepth: String(getRouteDepth(path) + 1)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHelpMenu(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const settingMap = await SettingAPI.map("GAO_SYSTEM", undefined, ["HELP_MENU"]);
|
||||
const settingValue = settingMap.HELP_MENU?.value;
|
||||
helpMenu.value = parseHelpMenu(settingValue);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "使用说明配置加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHelpMenu();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.help-page {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: var(--app-nav-height) 0 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.search-wrap {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
padding: .75rem 1rem;
|
||||
position: sticky;
|
||||
background: #F5F5F8;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
flex-direction: column;
|
||||
|
||||
.search {
|
||||
--td-button-border-radius: .75rem;
|
||||
--td-input-vertical-padding: .25rem 1rem;
|
||||
|
||||
flex: 1;
|
||||
border: 1px solid var(--td-component-border);
|
||||
z-index: 2;
|
||||
background: var(--td-bg-color-container);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.help-page {
|
||||
.search-wrap {
|
||||
background: #10161f;
|
||||
}
|
||||
|
||||
.search {
|
||||
background: rgba(30, 41, 59, .9);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="help-article-page">
|
||||
<template v-if="article">
|
||||
<article-view
|
||||
:article="article"
|
||||
empty-text="暂无使用说明正文"
|
||||
v-entrance-transition
|
||||
:resolve-attachment-url="resolveAttachmentUrl"
|
||||
/>
|
||||
</template>
|
||||
<t-empty
|
||||
v-else
|
||||
:description="isLoading ? '正在加载使用说明' : '使用说明不存在'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Article, ArticleAPI, AttachmentAPI } from "timi-web-api";
|
||||
import { ArticleView } from "timi-tdesign-mobile";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
|
||||
defineOptions({
|
||||
name: "HelpArticle"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const navBarStore = useNavBarStore();
|
||||
const article = ref<Article>();
|
||||
const isLoading = ref(false);
|
||||
let loadToken = 0;
|
||||
|
||||
function resolveAttachmentUrl(attachmentId: string): string {
|
||||
return AttachmentAPI.getReadURL(attachmentId);
|
||||
}
|
||||
|
||||
function getRouteNavBarTitle(): string {
|
||||
return typeof route.meta.navBarTitle === "string" ? route.meta.navBarTitle : "";
|
||||
}
|
||||
|
||||
async function loadArticle(): Promise<void> {
|
||||
const requestToken = loadToken + 1;
|
||||
loadToken = requestToken;
|
||||
article.value = undefined;
|
||||
navBarStore.setTitle(getRouteNavBarTitle());
|
||||
const articleId = String(route.params.id || "").trim();
|
||||
if (!articleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await ArticleAPI.detail(articleId);
|
||||
if (requestToken !== loadToken) {
|
||||
return;
|
||||
}
|
||||
article.value = result;
|
||||
navBarStore.setTitle(result.title?.trim() || getRouteNavBarTitle());
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "使用说明加载失败"));
|
||||
} finally {
|
||||
if (requestToken === loadToken) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.params.id, async () => {
|
||||
await loadArticle();
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.help-article-page {
|
||||
padding: var(--app-nav-height) 0 1rem;
|
||||
|
||||
.header {
|
||||
padding: .5rem 1rem;
|
||||
|
||||
.description {
|
||||
margin-top: .5rem;
|
||||
color: #52607a;
|
||||
font-size: .875rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.content) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.help-article-page {
|
||||
.header {
|
||||
.description {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,7 @@
|
||||
>
|
||||
<template #title>
|
||||
<div class="header">
|
||||
<div class="text-bold" v-text="item.name || '未命名门店'"></div>
|
||||
<div class="title text-bold clip-text" v-text="item.name || '未命名门店'"></div>
|
||||
<t-tag size="small" :theme="getStoreStatusTheme(item.status)" variant="outline" :content="getStoreStatusText(item.status)" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -259,9 +259,17 @@ onUnmounted(() => {
|
||||
|
||||
.item {
|
||||
|
||||
:deep(.t-cell__title) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
<template>
|
||||
<div class="entry-page">
|
||||
<section class="entry-list">
|
||||
<div
|
||||
v-entrance-transition="index * 70"
|
||||
v-for="(item, index) in visibleEntryList"
|
||||
:key="item.path"
|
||||
class="item"
|
||||
:class="{ wide: item.wide }"
|
||||
@click="go(item.path)"
|
||||
>
|
||||
<div class="home-tab">
|
||||
<section
|
||||
v-for="group in visibleEntryGroupList"
|
||||
:key="group.title"
|
||||
class="entry-group"
|
||||
>
|
||||
<h2 v-text="group.title" />
|
||||
<div class="items" :class="group.layout">
|
||||
<div
|
||||
v-entrance-transition="index * 70"
|
||||
v-for="(item, index) in group.entryList"
|
||||
:key="item.path"
|
||||
class="item"
|
||||
:class="{ quick: item.path === '/customer/quick-sign-in' }"
|
||||
@click="go(item.path)"
|
||||
>
|
||||
<div class="body">
|
||||
<t-icon :name="item.icon" size="1.75rem" />
|
||||
<h2 v-text="item.title" />
|
||||
</div>
|
||||
<p v-text="item.desc" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="tips gray" v-entrance-transition="visibleEntryList.length * 70">微信内打开本站可能会经常在扫码时需要授权,建议使用浏览器打开(右上角菜单,使用默认浏览器打开),并添加快捷入口到桌面。</div>
|
||||
<t-button
|
||||
class="help"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
content="使用帮助"
|
||||
@click="go('/management/help')"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="help-circle" />
|
||||
</template>
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,32 +51,81 @@ const userStore = useUserStore();
|
||||
const unreadNotifyCount = ref(0);
|
||||
let isPageActive = true;
|
||||
|
||||
const entryList = [
|
||||
interface HomeEntry {
|
||||
path: string;
|
||||
permission: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface HomeEntryGroup {
|
||||
title: string;
|
||||
layout?: "customer";
|
||||
entryList: HomeEntry[];
|
||||
}
|
||||
|
||||
const entryGroupList: HomeEntryGroup[] = [
|
||||
{
|
||||
path: "/customer/quick-sign-in",
|
||||
permission: permissionCodes.eventRecord.create,
|
||||
icon: "scan",
|
||||
title: "快速扫码登记",
|
||||
desc: "扫码选择客户并按配置事件登记",
|
||||
wide: true
|
||||
title: "客户管理",
|
||||
layout: "customer",
|
||||
entryList: [
|
||||
{
|
||||
path: "/management/customers/create?redirect=/customers",
|
||||
permission: permissionCodes.customer.create,
|
||||
icon: "user-add",
|
||||
title: "录入"
|
||||
},
|
||||
{
|
||||
path: "/customer/registration",
|
||||
permission: permissionCodes.eventRecord.create,
|
||||
icon: "task-checked",
|
||||
title: "登记"
|
||||
},
|
||||
{
|
||||
path: "/customer/quick-sign-in",
|
||||
permission: permissionCodes.eventRecord.create,
|
||||
icon: "scan",
|
||||
title: "快速扫码登记"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/management/customers/create?redirect=/customers",
|
||||
permission: permissionCodes.customer.create,
|
||||
icon: "edit-1",
|
||||
title: "录入",
|
||||
desc: "新建客户档案并完善基础资料"
|
||||
},
|
||||
{
|
||||
path: "/customer/registration",
|
||||
permission: permissionCodes.eventRecord.create,
|
||||
icon: "task-checked",
|
||||
title: "登记",
|
||||
desc: "按流程完成客户登记"
|
||||
title: "订单管理",
|
||||
entryList: [
|
||||
{
|
||||
path: "/management/sales-orders",
|
||||
permission: permissionCodes.salesOrder.read,
|
||||
icon: "task",
|
||||
title: "订单列表"
|
||||
},
|
||||
{
|
||||
path: "/management/sales-orders/create",
|
||||
permission: permissionCodes.salesOrder.create,
|
||||
icon: "task-add",
|
||||
title: "创建订单"
|
||||
},
|
||||
{
|
||||
path: "/management/customer-packages/create",
|
||||
permission: permissionCodes.customerPackage.create,
|
||||
icon: "layers",
|
||||
title: "创建套餐订单"
|
||||
},
|
||||
{
|
||||
path: "/management/points/redemptions",
|
||||
permission: permissionCodes.pointRedemption.create,
|
||||
icon: "gift",
|
||||
title: "积分兑换"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const visibleEntryList = computed(() => entryList.filter(item => userStore.hasPermissions(item.permission)));
|
||||
const visibleEntryGroupList = computed(() => entryGroupList
|
||||
.map(group => ({
|
||||
...group,
|
||||
entryList: group.entryList.filter(item => userStore.hasPermissions(item.permission))
|
||||
}))
|
||||
.filter(group => group.entryList.length));
|
||||
const canReadNotify = computed(() => userStore.hasPermissions(corePermissionCodes.notify.read));
|
||||
|
||||
async function go(path: string): Promise<void> {
|
||||
@@ -99,80 +164,92 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.entry-page {
|
||||
.home-tab {
|
||||
gap: 2rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.entry-list {
|
||||
display: grid;
|
||||
gap: .875rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.entry-group {
|
||||
|
||||
.item {
|
||||
color: #0f766e;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||
align-items: center;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 1.4rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.body {
|
||||
inset: 1rem;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
& + & {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
> h2 {
|
||||
color: #52607a;
|
||||
margin: 0 0 .75rem 0;
|
||||
font-size: .875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
color: #7b889d;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
font-size: .75rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.items {
|
||||
gap: .875rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
&.wide {
|
||||
grid-column: 1 / -1;
|
||||
aspect-ratio: 2 / 1;
|
||||
.item {
|
||||
--entry-color: #0f766e;
|
||||
--entry-background: rgba(255, 255, 255, .92);
|
||||
|
||||
color: var(--entry-color);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: var(--entry-background);
|
||||
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||
align-items: center;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 1.4rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
&.quick {
|
||||
color: var(--entry-background);
|
||||
background: var(--entry-color);
|
||||
}
|
||||
|
||||
.body {
|
||||
inset: 1rem;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: .75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
font-size: .875rem;
|
||||
margin-top: 1rem;
|
||||
text-indent: 2em;
|
||||
.help {
|
||||
--td-button-border-radius: 1rem;
|
||||
|
||||
width: 8rem;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
box-shadow: var(--td-shadow-3);
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.entry-page {
|
||||
.entry-group > h2 {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
--entry-background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
|
||||
p {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -119,6 +119,23 @@ const menuGroupList = computed<MenuGroup[]>(() => [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "积分兑换",
|
||||
items: [
|
||||
{
|
||||
icon: "cart",
|
||||
path: "/management/points/redeem-skus",
|
||||
title: "兑换商品",
|
||||
visible: userStore.hasPermissions(permissionCodes.pointRedeemSku.manage)
|
||||
},
|
||||
{
|
||||
icon: "gift",
|
||||
path: "/management/points/redemptions",
|
||||
title: "积分兑换",
|
||||
visible: userStore.hasPermissions(permissionCodes.pointRedemption.create)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "经营分析",
|
||||
items: [
|
||||
|
||||
@@ -238,7 +238,14 @@ const menuGroupList = computed<MenuGroup[]>(() => [
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "其他",
|
||||
items: [
|
||||
{
|
||||
icon: "file-1",
|
||||
path: "/management/help",
|
||||
title: "使用说明",
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
icon: "info-circle",
|
||||
path: "/management/about",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
/>
|
||||
</div>
|
||||
</t-form>
|
||||
<t-button class="submit" theme="primary" size="medium" :loading="isSubmitting" :disabled="isExpired || isSubmitting" @click="register">注册并登录</t-button>
|
||||
<user-agreement v-model="hasAgreed" />
|
||||
<t-button class="submit" theme="primary" size="medium" :loading="isSubmitting" :disabled="isExpired || isSubmitting || !hasAgreed" @click="register">注册并登录</t-button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,6 +62,7 @@ import { CommonAPI, NotifyAPI, useUserStore, type LoginResponse } from "timi-web
|
||||
import ManagedUserAPI from "@/api/ManagedUserAPI";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import Logo from "@/pages/user/component/Logo.vue";
|
||||
import UserAgreement from "@/pages/user/component/UserAgreement.vue";
|
||||
|
||||
type CaptchaExpose = { captchaId: string; update: () => Promise<void> };
|
||||
|
||||
@@ -75,6 +77,7 @@ const isValid = ref(false);
|
||||
const expireAt = ref(0);
|
||||
const invalidMessage = ref("邀请码无效或已过期,请联系邀请人重新生成。");
|
||||
const isSubmitting = ref(false);
|
||||
const hasAgreed = ref(false);
|
||||
const captchaRef = ref<CaptchaExpose | null>(null);
|
||||
const formModel = reactive({
|
||||
phoneNo: "",
|
||||
@@ -193,7 +196,11 @@ async function sendSms(): Promise<void> {
|
||||
}
|
||||
|
||||
async function register(): Promise<void> {
|
||||
if (isSubmitting.value || isExpired.value || !validateForm()) {
|
||||
if (isSubmitting.value || !hasAgreed.value || isExpired.value || !validateForm()) {
|
||||
if (!hasAgreed.value) {
|
||||
Message.warning("请先阅读并同意用户协议");
|
||||
return;
|
||||
}
|
||||
if (isExpired.value) {
|
||||
Message.warning("邀请码已失效,请联系邀请人重新生成");
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
:options="loginTypeOptions"
|
||||
v-entrance-transition="140"
|
||||
/>
|
||||
<password-login v-if="loginType === 'password'" class="form" />
|
||||
<sms-login v-else class="form" />
|
||||
<password-login v-if="loginType === 'password'" class="form" :agreement-accepted="hasAgreed" />
|
||||
<sms-login v-else class="form" :agreement-accepted="hasAgreed" />
|
||||
<user-agreement v-model="hasAgreed" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,6 +22,7 @@
|
||||
import PasswordLogin from "@/pages/user/component/PasswordLogin.vue";
|
||||
import SmsLogin from "@/pages/user/component/SmsLogin.vue";
|
||||
import Logo from "@/pages/user/component/Logo.vue";
|
||||
import UserAgreement from "@/pages/user/component/UserAgreement.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "LoginIndex"
|
||||
@@ -29,6 +31,7 @@ defineOptions({
|
||||
type LoginType = "password" | "sms";
|
||||
|
||||
const loginType = ref<LoginType>("password");
|
||||
const hasAgreed = ref(false);
|
||||
const loginTypeOptions = [
|
||||
{ value: "password" as LoginType, label: "密码登录" },
|
||||
{ value: "sms" as LoginType, label: "短信登录" }
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
<t-button
|
||||
class="red bg-white cell-button"
|
||||
variant="text"
|
||||
block
|
||||
v-entrance-transition="140"
|
||||
@click="confirmLogout"
|
||||
>退出登录</t-button>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<captcha class="captcha" ref="captchaRef" :width="120" :height="40" :request="CommonAPI.captcha" />
|
||||
</div>
|
||||
<div class="exec">
|
||||
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting">登录</t-button>
|
||||
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting || !props.agreementAccepted">登录</t-button>
|
||||
</div>
|
||||
</t-form>
|
||||
</template>
|
||||
@@ -39,6 +39,12 @@ defineOptions({
|
||||
name: "PasswordLogin"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
agreementAccepted?: boolean;
|
||||
}>(), {
|
||||
agreementAccepted: false
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
@@ -56,6 +62,10 @@ function getRedirectPath(): string {
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!props.agreementAccepted) {
|
||||
Message.warning("请先阅读并同意用户协议");
|
||||
return false;
|
||||
}
|
||||
if (!formModel.user || !formModel.password || !formModel.captcha || !captchaRef.value?.captchaId) {
|
||||
Message.warning("请输入账号、密码和验证码");
|
||||
return false;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="exec">
|
||||
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting">登录</t-button>
|
||||
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting || !props.agreementAccepted">登录</t-button>
|
||||
</div>
|
||||
</t-form>
|
||||
</template>
|
||||
@@ -51,6 +51,12 @@ defineOptions({
|
||||
name: "SmsLogin"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
agreementAccepted?: boolean;
|
||||
}>(), {
|
||||
agreementAccepted: false
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
@@ -136,6 +142,10 @@ async function sendSms(): Promise<void> {
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!props.agreementAccepted) {
|
||||
Message.warning("请先阅读并同意用户协议");
|
||||
return false;
|
||||
}
|
||||
if (!/^1\d{10}$/.test(formModel.mobilePhone)) {
|
||||
Message.warning("请输入正确的 11 位手机号");
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="user-agreement">
|
||||
<t-checkbox
|
||||
:model-value="props.modelValue"
|
||||
class="checkbox"
|
||||
borderless
|
||||
@change="handleCheckboxChange"
|
||||
>
|
||||
<span>我已阅读并同意用户协议</span>
|
||||
</t-checkbox>
|
||||
</div>
|
||||
<t-dialog
|
||||
class="user-agreement-dialog"
|
||||
v-model:visible="dialogVisible"
|
||||
title="用户协议"
|
||||
cancel-btn="拒绝"
|
||||
confirm-btn="同意"
|
||||
:close-btn="false"
|
||||
:close-on-overlay-click="false"
|
||||
destroy-on-close
|
||||
@cancel="rejectAgreement"
|
||||
@confirm="agreeAgreement"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<t-tabs
|
||||
v-model="activeAgreementCode"
|
||||
class="tabs"
|
||||
theme="line"
|
||||
bottom-line-mode="full"
|
||||
:split="false"
|
||||
:space-evenly="true"
|
||||
:swipeable="true"
|
||||
>
|
||||
<t-tab-panel
|
||||
v-for="item in agreementList"
|
||||
:key="item.code"
|
||||
:value="item.code"
|
||||
:label="item.title"
|
||||
lazy
|
||||
>
|
||||
<div class="agreement-item">
|
||||
<t-loading v-if="item.loading" class="loading" text="正在加载协议" />
|
||||
<article-view
|
||||
v-else-if="item.article"
|
||||
:article="item.article"
|
||||
empty-text="暂无协议正文"
|
||||
:resolve-attachment-url="resolveAttachmentUrl"
|
||||
/>
|
||||
<p v-else class="error" v-text="item.errorMessage || '协议加载失败,请拒绝后重试。'" />
|
||||
</div>
|
||||
</t-tab-panel>
|
||||
</t-tabs>
|
||||
<div class="tips">
|
||||
<span>请阅读以上全部协议</span>
|
||||
<br />
|
||||
<span>阅读完成后点击“同意”继续</span>
|
||||
</div>
|
||||
</div>
|
||||
</t-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArticleAPI, AttachmentAPI, SettingAPI, type Article } from "timi-web-api";
|
||||
import { ArticleView } from "timi-tdesign-mobile";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
|
||||
type AgreementCode = "SERVICE_LICENSE" | "PRIVACY_LICENSE" | "OPEN_SOURCE_LICENSE";
|
||||
type AgreementItem = {
|
||||
code: AgreementCode;
|
||||
title: string;
|
||||
article?: Article;
|
||||
errorMessage?: string;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
const baseAgreementList: Array<Pick<AgreementItem, "code" | "title">> = [
|
||||
{
|
||||
code: "SERVICE_LICENSE",
|
||||
title: "服务协议"
|
||||
},
|
||||
{
|
||||
code: "PRIVACY_LICENSE",
|
||||
title: "隐私协议"
|
||||
},
|
||||
{
|
||||
code: "OPEN_SOURCE_LICENSE",
|
||||
title: "开源软件协议"
|
||||
}
|
||||
];
|
||||
|
||||
defineOptions({
|
||||
name: "UserAgreement"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: boolean;
|
||||
}>(), {
|
||||
modelValue: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
}>();
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const agreementList = ref<AgreementItem[]>(createAgreementList());
|
||||
const activeAgreementCode = ref<AgreementCode>("SERVICE_LICENSE");
|
||||
|
||||
function createAgreementList(): AgreementItem[] {
|
||||
return baseAgreementList.map(item => ({
|
||||
...item,
|
||||
loading: false
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCheckboxChange(checked: boolean): void {
|
||||
if (!checked) {
|
||||
emit("update:modelValue", false);
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update:modelValue", false);
|
||||
dialogVisible.value = true;
|
||||
activeAgreementCode.value = "SERVICE_LICENSE";
|
||||
void loadAgreements();
|
||||
}
|
||||
|
||||
async function loadAgreements(): Promise<void> {
|
||||
agreementList.value = createAgreementList().map(item => ({
|
||||
...item,
|
||||
loading: true
|
||||
}));
|
||||
try {
|
||||
const settingMap = await SettingAPI.map("GAO_SYSTEM");
|
||||
agreementList.value = await Promise.all(agreementList.value.map(async item => {
|
||||
const articleId = settingMap[item.code]?.value?.trim();
|
||||
if (!articleId) {
|
||||
return {
|
||||
...item,
|
||||
loading: false,
|
||||
errorMessage: `${item.title}暂未配置`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
...item,
|
||||
article: await ArticleAPI.detail(articleId),
|
||||
loading: false
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...item,
|
||||
loading: false,
|
||||
errorMessage: getErrorMessage(error, `${item.title}加载失败`)
|
||||
};
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, "协议配置加载失败");
|
||||
agreementList.value = createAgreementList().map(item => ({
|
||||
...item,
|
||||
loading: false,
|
||||
errorMessage
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAttachmentUrl(attachmentId: string): string {
|
||||
return AttachmentAPI.getReadURL(attachmentId);
|
||||
}
|
||||
|
||||
function rejectAgreement(): void {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function agreeAgreement(): void {
|
||||
emit("update:modelValue", true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.user-agreement {
|
||||
display: flex;
|
||||
margin-top: .75rem;
|
||||
justify-content: center;
|
||||
|
||||
.checkbox {
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.user-agreement-dialog {
|
||||
|
||||
:global(.user-agreement-dialog.t-dialog .t-dialog__content) {
|
||||
height: min(62vh, 36rem);
|
||||
max-height: min(62vh, 36rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.user-agreement-dialog.t-dialog .t-dialog__body) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.user-agreement-dialog.t-dialog .t-dialog__body-text) {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:global(.user-agreement-dialog.t-dialog .t-dialog__footer) {
|
||||
--td-spacer-1: 0;
|
||||
--td-button-border-radius: 1rem;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
--td-spacer-3: 10px;
|
||||
|
||||
gap: .5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
|
||||
.tabs {
|
||||
--td-tab-item-height: 2rem;
|
||||
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.t-tabs__content) {
|
||||
flex: 1 1 auto;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.t-tabs__scroll) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:deep(.t-tabs__item) {
|
||||
padding: 0;
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-item {
|
||||
padding-top: 1rem;
|
||||
|
||||
.loading {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b42318;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:deep(.content) {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
color: #52607a;
|
||||
line-height: 1.5;
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.user-agreement {
|
||||
.checkbox {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.user-agreement-dialog {
|
||||
.dialog-content {
|
||||
.hint {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.agreement-item {
|
||||
.error {
|
||||
color: #fda29b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -130,6 +130,35 @@ export const storeRoutes: RouteRecordRaw[] = [
|
||||
];
|
||||
|
||||
export const managementSettingRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/management/help/article/:id",
|
||||
name: "HelpArticle",
|
||||
component: () => import("@/pages/setting/HelpArticle.vue"),
|
||||
meta: {
|
||||
depth: 4,
|
||||
navBarVisible: true,
|
||||
navBarCanBack: true,
|
||||
navBarTitle: "使用说明",
|
||||
tabBarVisible: false,
|
||||
bodyBackground: "#FFF",
|
||||
darkBodyBackground: "#10161f"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "/management/help/:pathMatch(.*)*",
|
||||
name: "HelpMenu",
|
||||
component: () => import("@/pages/setting/Help.vue"),
|
||||
meta: {
|
||||
baseDepth: 2,
|
||||
dynamicDepth: true,
|
||||
navBarVisible: true,
|
||||
navBarCanBack: true,
|
||||
navBarTitle: "使用说明",
|
||||
tabBarVisible: false,
|
||||
bodyBackground: "#F5F5F8",
|
||||
darkBodyBackground: "#10161f"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "/management/about",
|
||||
name: "About",
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
const pointRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/management/points/redeem-skus", name: "PointRedeemSkuList", component: () => import("@/pages/point/PointRedeemSkuList.vue"),
|
||||
meta: { depth: 3, navBarVisible: true, navBarCanBack: true, navBarTitle: "兑换商品", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redeem-skus/create", name: "PointRedeemSkuCreate", component: () => import("@/pages/point/PointRedeemSkuEdit.vue"),
|
||||
meta: { depth: 4, navBarVisible: true, navBarCanBack: true, navBarTitle: "新增兑换商品", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redeem-skus/:id/edit", name: "PointRedeemSkuEdit", component: () => import("@/pages/point/PointRedeemSkuEdit.vue"),
|
||||
meta: { depth: 4, navBarVisible: true, navBarCanBack: true, navBarTitle: "编辑兑换商品", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redemptions", name: "PointRedemptionList", component: () => import("@/pages/point/PointRedemptionList.vue"),
|
||||
meta: { depth: 3, navBarVisible: true, navBarCanBack: true, navBarTitle: "积分兑换", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redemptions/create", name: "PointRedemptionCreate", component: () => import("@/pages/point/PointRedemptionCreate.vue"),
|
||||
meta: { depth: 4, navBarVisible: true, navBarCanBack: true, navBarTitle: "客户兑换", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redemptions/:id", name: "PointRedemptionDetail", component: () => import("@/pages/point/PointRedemptionDetail.vue"),
|
||||
meta: { depth: 4, navBarVisible: true, navBarCanBack: true, navBarTitle: "兑换详情", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/account",
|
||||
name: "PointAccountList",
|
||||
|
||||
@@ -29,6 +29,10 @@ export const useNavBarStore = defineStore("nav-bar", () => {
|
||||
height.value = value;
|
||||
}
|
||||
|
||||
function setTitle(value?: string): void {
|
||||
title.value = value || "";
|
||||
}
|
||||
|
||||
function setBackTo(value?: string): void {
|
||||
backTo.value = value;
|
||||
}
|
||||
@@ -106,6 +110,7 @@ export const useNavBarStore = defineStore("nav-bar", () => {
|
||||
isShowing,
|
||||
canBack,
|
||||
setHeight,
|
||||
setTitle,
|
||||
setBackTo,
|
||||
setRightIcon,
|
||||
setRightBadge,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Attachment, Model, PageResult, User } from "timi-web-api";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { OrderFulfillment } from "@/types/Fulfillment";
|
||||
import type { ProductSku } from "@/types/Product";
|
||||
import type { GaoSalesOrderItem, GaoSalesOrderStatus, SalesOrder } from "@/types/SalesOrder";
|
||||
import type { GaoSalesOrderItem, GaoSalesOrderStatus, SalesOrder, SalesOrderStraightThroughRequest } from "@/types/SalesOrder";
|
||||
|
||||
export type PackageConfigStatus = "DRAFT" | "ACTIVE" | "INACTIVE";
|
||||
|
||||
@@ -79,6 +79,7 @@ export type PackagePurchaseRequest = {
|
||||
purchasedAt?: number;
|
||||
itemList?: PackagePurchaseItem[];
|
||||
attachmentList?: PackageAttachment[];
|
||||
straightThrough?: SalesOrderStraightThroughRequest;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -108,3 +108,94 @@ export type PointAdjustRequest = {
|
||||
pointDelta: number;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
export type PointRedeemSkuStatus = "DRAFT" | "ACTIVE" | "INACTIVE" | "DELETED";
|
||||
export type PointRedemptionStatus = "COMPLETED" | "PARTIALLY_RETURNED" | "FULLY_RETURNED";
|
||||
|
||||
export type PointRedeemSku = {
|
||||
storeId?: string;
|
||||
skuId?: string;
|
||||
skuUnitId?: string;
|
||||
status?: PointRedeemSkuStatus;
|
||||
defaultPointCost?: number;
|
||||
beginAt?: number | null;
|
||||
endAt?: number | null;
|
||||
maxTotalQuantity?: number | string | null;
|
||||
maxDailyQuantity?: number | string | null;
|
||||
maxMonthlyQuantity?: number | string | null;
|
||||
remark?: string | null;
|
||||
product?: import("@/types/Product").Product;
|
||||
sku?: import("@/types/Product").ProductSku;
|
||||
unit?: import("@/types/Product").ProductSkuUnit;
|
||||
} & Model;
|
||||
|
||||
export type PointRedeemSkuPage = {
|
||||
index: number;
|
||||
size: number;
|
||||
storeId?: string;
|
||||
status?: PointRedeemSkuStatus;
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
export type PointRedemption = {
|
||||
storeId?: string;
|
||||
customerId?: string;
|
||||
requestNo?: string;
|
||||
redeemSkuIdSnapshot?: string;
|
||||
productNameSnapshot?: string;
|
||||
productTypeSnapshot?: string;
|
||||
skuCodeSnapshot?: string;
|
||||
skuNameSnapshot?: string;
|
||||
unitSnapshot?: string;
|
||||
quantity?: number | string;
|
||||
defaultPointCost?: number;
|
||||
actualPointCost?: number;
|
||||
returnedQuantity?: number | string;
|
||||
returnedPointCost?: number;
|
||||
status?: PointRedemptionStatus;
|
||||
redeemedAt?: number;
|
||||
remark?: string | null;
|
||||
customer?: Customer;
|
||||
attachmentList?: import("timi-web-api").Attachment[];
|
||||
returnList?: PointRedemptionReturn[];
|
||||
} & Model;
|
||||
|
||||
export type PointRedemptionReturn = {
|
||||
redemptionId?: string;
|
||||
requestNo?: string;
|
||||
quantity?: number | string;
|
||||
pointCost?: number;
|
||||
returnedAt?: number;
|
||||
remark?: string | null;
|
||||
attachmentList?: import("timi-web-api").Attachment[];
|
||||
} & Model;
|
||||
|
||||
export type PointRedemptionPage = {
|
||||
index: number;
|
||||
size: number;
|
||||
storeId?: string;
|
||||
customerId?: string;
|
||||
keyword?: string;
|
||||
status?: PointRedemptionStatus;
|
||||
beginAt?: number;
|
||||
endAt?: number;
|
||||
};
|
||||
|
||||
export type PointRedemptionCreateRequest = {
|
||||
requestNo: string;
|
||||
storeId?: string;
|
||||
customerId: string;
|
||||
redeemSkuId: string;
|
||||
quantity: string;
|
||||
actualPointCost?: number;
|
||||
attachmentList: Array<{ tempFileId: string; attachType: "PHOTO"; name?: string }>;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
export type PointRedemptionReturnRequest = {
|
||||
redemptionId: string;
|
||||
requestNo: string;
|
||||
quantity: string;
|
||||
attachmentList: Array<{ tempFileId: string; attachType: "PHOTO"; name?: string }>;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
@@ -98,6 +98,19 @@ export type DirectProductOrderItemRequest = {
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
export type SalesOrderStraightThroughRequest = {
|
||||
paymentVerified?: boolean;
|
||||
paymentAmount?: number;
|
||||
receiveFullPayment?: boolean;
|
||||
paymentOccurredAt?: number;
|
||||
paymentAttachmentList?: Partial<Attachment>[];
|
||||
paymentRemark?: string | null;
|
||||
fulfillAll?: boolean;
|
||||
deliveredAt?: number;
|
||||
fulfillmentAttachmentList?: Partial<Attachment>[];
|
||||
fulfillmentRemark?: string | null;
|
||||
};
|
||||
|
||||
export type DirectProductOrderCreateRequest = {
|
||||
requestNo: string;
|
||||
storeId?: string;
|
||||
@@ -105,6 +118,7 @@ export type DirectProductOrderCreateRequest = {
|
||||
purchasedAt?: number;
|
||||
itemList: DirectProductOrderItemRequest[];
|
||||
attachmentList?: Partial<Attachment>[];
|
||||
straightThrough?: SalesOrderStraightThroughRequest;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ export const permissionCodes = {
|
||||
adjust: "GAO:POINT:ADJUST",
|
||||
sourceValue: "GAO:POINT:SOURCE_VALUE"
|
||||
},
|
||||
pointRedeemSku: {
|
||||
manage: "GAO:POINT_REDEEM_SKU:MANAGE"
|
||||
},
|
||||
pointRedemption: {
|
||||
create: "GAO:POINT_REDEMPTION:CREATE",
|
||||
pointAdjust: "GAO:POINT_REDEMPTION:POINT_ADJUST",
|
||||
return: "GAO:POINT_REDEMPTION:RETURN"
|
||||
},
|
||||
pointRule: {
|
||||
create: "GAO:POINT_RULE:CREATE",
|
||||
read: "GAO:POINT_RULE:READ",
|
||||
@@ -188,6 +196,10 @@ export const gaoStoreScopePermissionCodes = [
|
||||
permissionCodes.point.ledgerRead,
|
||||
permissionCodes.point.adjust,
|
||||
permissionCodes.point.sourceValue,
|
||||
permissionCodes.pointRedeemSku.manage,
|
||||
permissionCodes.pointRedemption.create,
|
||||
permissionCodes.pointRedemption.pointAdjust,
|
||||
permissionCodes.pointRedemption.return,
|
||||
permissionCodes.pointRule.create,
|
||||
permissionCodes.pointRule.read,
|
||||
permissionCodes.pointRule.update,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.gradle/
|
||||
local.properties
|
||||
**/build/
|
||||
*.apk
|
||||
*.aab
|
||||
*.idsig
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
@@ -0,0 +1,41 @@
|
||||
# TWA 构建说明
|
||||
|
||||
本目录是 `gao.imyeyu.com` 的 Trusted Web Activity 工程。APK 只包含 Android 启动壳,业务页面始终从 `https://gao.imyeyu.com/` 加载,不把前端 `dist` 打进 APK。
|
||||
|
||||
## 构建环境
|
||||
|
||||
- JDK 17
|
||||
- Android SDK
|
||||
- Bubblewrap CLI 1.25.0
|
||||
|
||||
在本机首次使用 Bubblewrap 时执行:
|
||||
|
||||
```powershell
|
||||
pnpm --config.registry=https://registry.npmjs.org dlx @bubblewrap/cli@1.25.0 doctor
|
||||
```
|
||||
|
||||
## 构建 APK
|
||||
|
||||
首次构建正式版本时,先在本目录生成并妥善备份 release keystore:
|
||||
|
||||
```powershell
|
||||
keytool -genkeypair -v -keystore .\android.keystore -alias gao-customer -keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
|
||||
```powershell
|
||||
pnpm --config.registry=https://registry.npmjs.org dlx @bubblewrap/cli@1.25.0 build
|
||||
```
|
||||
|
||||
构建前请准备长期保存的 release keystore,并确认 `twa-manifest.json` 中的 `signingKey` 指向它。当前目录的 `signingKey` 是可提交的相对路径模板;本次验证 APK 使用的是本机调试签名,不能作为正式发布签名。
|
||||
|
||||
构建完成后,用 release keystore 的 SHA-256 证书指纹更新 `../public/.well-known/assetlinks.json`,再发布前端 `dist`。TWA 只有在网站的 `/.well-known/assetlinks.json` 与 APK 包名、签名一致时,才会进入无地址栏的全屏模式。
|
||||
|
||||
当前 `assetlinks.json` 中登记的是本机调试证书指纹,只能配合本次验证 APK 使用。正式发布前必须替换为 release 证书指纹;如果使用 Google Play,还要把 Play App Signing 证书指纹一并登记。
|
||||
|
||||
## 前端发布顺序
|
||||
|
||||
1. 先上传新版本 `dist/assets`。
|
||||
2. 再上传新的 `index.html`、Manifest 和其他静态文件。
|
||||
3. 最后检查 `https://gao.imyeyu.com/`、`/manifest.webmanifest` 和 `/.well-known/assetlinks.json`。
|
||||
|
||||
`index.html` 不缓存,带哈希的 `/assets/` 长缓存。不要给前端资源增加 Service Worker 缓存,除非同时实现版本更新和回滚,否则会阻止 TWA 获取最新页面。
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import groovy.xml.MarkupBuilder
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
|
||||
def twaManifest = [
|
||||
applicationId: 'com.imyeyu.gao.customer',
|
||||
hostName: 'gao.imyeyu.com', // The domain being opened in the TWA.
|
||||
launchUrl: '/', // The start path for the TWA. Must be relative to the domain.
|
||||
name: '糕店客户管理', // The application name.
|
||||
launcherName: '糕店管理', // The name shown on the Android Launcher.
|
||||
themeColor: '#FFFFFF', // The color used for the status bar.
|
||||
themeColorDark: '#000000', // The color used for the dark status bar.
|
||||
navigationColor: '#000000', // The color used for the navigation bar.
|
||||
navigationColorDark: '#000000', // The color used for the dark navbar.
|
||||
navigationDividerColor: '#000000', // The navbar divider color.
|
||||
navigationDividerColorDark: '#000000', // The dark navbar divider color.
|
||||
backgroundColor: '#FFFFFF', // The color used for the splash screen background.
|
||||
enableNotifications: false, // Set to true to enable notification delegation.
|
||||
// Every shortcut must include the following fields:
|
||||
// - name: String that will show up in the shortcut.
|
||||
// - short_name: Shorter string used if |name| is too long.
|
||||
// - url: Absolute path of the URL to launch the app with (e.g '/create').
|
||||
// - icon: Name of the resource in the drawable folder to use as an icon.
|
||||
shortcuts: [],
|
||||
// The duration of fade out animation in milliseconds to be played when removing splash screen.
|
||||
splashScreenFadeOutDuration: 300,
|
||||
generatorApp: 'bubblewrap-cli', // Application that generated the Android Project
|
||||
// The fallback strategy for when Trusted Web Activity is not available. Possible values are
|
||||
// 'customtabs' and 'webview'.
|
||||
fallbackType: 'customtabs',
|
||||
enableSiteSettingsShortcut: 'true',
|
||||
orientation: 'portrait',
|
||||
]
|
||||
|
||||
android {
|
||||
compileSdkVersion 36
|
||||
namespace "com.imyeyu.gao.customer"
|
||||
defaultConfig {
|
||||
applicationId "com.imyeyu.gao.customer"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 36
|
||||
versionCode 1
|
||||
versionName "1"
|
||||
|
||||
// The name for the application
|
||||
resValue "string", "appName", twaManifest.name
|
||||
|
||||
// The name for the application on the Android Launcher
|
||||
resValue "string", "launcherName", twaManifest.launcherName
|
||||
|
||||
// The URL that will be used when launching the TWA from the Android Launcher
|
||||
def launchUrl = "https://" + twaManifest.hostName + twaManifest.launchUrl
|
||||
resValue "string", "launchUrl", launchUrl
|
||||
|
||||
|
||||
|
||||
|
||||
// The URL the Web Manifest for the Progressive Web App that the TWA points to. This
|
||||
// is used by Chrome OS and Meta Quest to open the Web version of the PWA instead of
|
||||
// the TWA, as it will probably give a better user experience for non-mobile devices.
|
||||
resValue "string", "webManifestUrl", 'https://gao.imyeyu.com/manifest.webmanifest'
|
||||
|
||||
|
||||
|
||||
// This is used by Meta Quest.
|
||||
resValue "string", "fullScopeUrl", 'https://gao.imyeyu.com/'
|
||||
|
||||
|
||||
|
||||
|
||||
// The hostname is used when building the intent-filter, so the TWA is able to
|
||||
// handle Intents to open host url of the application.
|
||||
resValue "string", "hostName", twaManifest.hostName
|
||||
|
||||
// This attribute sets the status bar color for the TWA. It can be either set here or in
|
||||
// `res/values/colors.xml`. Setting in both places is an error and the app will not
|
||||
// compile. If not set, the status bar color defaults to #FFFFFF - white.
|
||||
resValue "color", "colorPrimary", twaManifest.themeColor
|
||||
|
||||
// This attribute sets the dark status bar color for the TWA. It can be either set here or in
|
||||
// `res/values/colors.xml`. Setting in both places is an error and the app will not
|
||||
// compile. If not set, the status bar color defaults to #000000 - white.
|
||||
resValue "color", "colorPrimaryDark", twaManifest.themeColorDark
|
||||
|
||||
// This attribute sets the navigation bar color for the TWA. It can be either set here or
|
||||
// in `res/values/colors.xml`. Setting in both places is an error and the app will not
|
||||
// compile. If not set, the navigation bar color defaults to #FFFFFF - white.
|
||||
resValue "color", "navigationColor", twaManifest.navigationColor
|
||||
|
||||
// This attribute sets the dark navigation bar color for the TWA. It can be either set here
|
||||
// or in `res/values/colors.xml`. Setting in both places is an error and the app will not
|
||||
// compile. If not set, the navigation bar color defaults to #000000 - black.
|
||||
resValue "color", "navigationColorDark", twaManifest.navigationColorDark
|
||||
|
||||
// This attribute sets the navbar divider color for the TWA. It can be either
|
||||
// set here or in `res/values/colors.xml`. Setting in both places is an error and the app
|
||||
// will not compile. If not set, the divider color defaults to #00000000 - transparent.
|
||||
resValue "color", "navigationDividerColor", twaManifest.navigationDividerColor
|
||||
|
||||
// This attribute sets the dark navbar divider color for the TWA. It can be either
|
||||
// set here or in `res/values/colors.xml`. Setting in both places is an error and the
|
||||
//app will not compile. If not set, the divider color defaults to #000000 - black.
|
||||
resValue "color", "navigationDividerColorDark", twaManifest.navigationDividerColorDark
|
||||
|
||||
// Sets the color for the background used for the splash screen when launching the
|
||||
// Trusted Web Activity.
|
||||
resValue "color", "backgroundColor", twaManifest.backgroundColor
|
||||
|
||||
// Defines a provider authority for the Splash Screen
|
||||
resValue "string", "providerAuthority", twaManifest.applicationId + '.fileprovider'
|
||||
|
||||
// The enableNotification resource is used to enable or disable the
|
||||
// TrustedWebActivityService, by changing the android:enabled and android:exported
|
||||
// attributes
|
||||
resValue "bool", "enableNotification", twaManifest.enableNotifications.toString()
|
||||
|
||||
twaManifest.shortcuts.eachWithIndex { shortcut, index ->
|
||||
resValue "string", "shortcut_name_$index", "$shortcut.name"
|
||||
resValue "string", "shortcut_short_name_$index", "$shortcut.short_name"
|
||||
}
|
||||
|
||||
// The splashScreenFadeOutDuration resource is used to set the duration of fade out animation in milliseconds
|
||||
// to be played when removing splash screen. The default is 0 (no animation).
|
||||
resValue "integer", "splashScreenFadeOutDuration", twaManifest.splashScreenFadeOutDuration.toString()
|
||||
|
||||
resValue "string", "generatorApp", twaManifest.generatorApp
|
||||
|
||||
resValue "string", "fallbackType", twaManifest.fallbackType
|
||||
|
||||
resValue "bool", "enableSiteSettingsShortcut", twaManifest.enableSiteSettingsShortcut
|
||||
resValue "string", "orientation", twaManifest.orientation
|
||||
|
||||
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
lintOptions {
|
||||
checkReleaseBuilds false
|
||||
}
|
||||
}
|
||||
|
||||
task generateShorcutsFile {
|
||||
assert twaManifest.shortcuts.size() < 5, "You can have at most 4 shortcuts."
|
||||
twaManifest.shortcuts.eachWithIndex { s, i ->
|
||||
assert s.name != null, 'Missing `name` in shortcut #' + i
|
||||
assert s.short_name != null, 'Missing `short_name` in shortcut #' + i
|
||||
assert s.url != null, 'Missing `icon` in shortcut #' + i
|
||||
assert s.icon != null, 'Missing `url` in shortcut #' + i
|
||||
}
|
||||
|
||||
def shortcutsFile = new File("$projectDir/src/main/res/xml", "shortcuts.xml")
|
||||
|
||||
def xmlWriter = new StringWriter()
|
||||
def xmlMarkup = new MarkupBuilder(new IndentPrinter(xmlWriter, " ", true))
|
||||
|
||||
xmlMarkup
|
||||
.'shortcuts'('xmlns:android': 'http://schemas.android.com/apk/res/android') {
|
||||
twaManifest.shortcuts.eachWithIndex { s, i ->
|
||||
'shortcut'(
|
||||
'android:shortcutId': 'shortcut' + i,
|
||||
'android:enabled': 'true',
|
||||
'android:icon': '@drawable/' + s.icon,
|
||||
'android:shortcutShortLabel': '@string/shortcut_short_name_' + i,
|
||||
'android:shortcutLongLabel': '@string/shortcut_name_' + i) {
|
||||
'intent'(
|
||||
'android:action': 'android.intent.action.MAIN',
|
||||
'android:targetPackage': twaManifest.applicationId,
|
||||
'android:targetClass': twaManifest.applicationId + '.LauncherActivity',
|
||||
'android:data': s.url)
|
||||
'categories'('android:name': 'android.intent.category.LAUNCHER')
|
||||
}
|
||||
}
|
||||
}
|
||||
shortcutsFile.text = xmlWriter.toString() + '\n'
|
||||
}
|
||||
|
||||
preBuild.dependsOn(generateShorcutsFile)
|
||||
|
||||
repositories {
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
|
||||
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.6.2'
|
||||
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<!--
|
||||
Copyright 2019 Google Inc. All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<!-- The "package" attribute is rewritten by the Gradle build with the value of applicationId.
|
||||
It is still required here, as it is used to derive paths, for instance when referring
|
||||
to an Activity by ".MyActivity" instead of the full name. If more Activities are added to the
|
||||
application, the package attribute will need to reflect the correct path in order to use
|
||||
the abbreviated format. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.imyeyu.gao.customer">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<application
|
||||
android:name="Application"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/appName"
|
||||
|
||||
android:manageSpaceActivity="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
|
||||
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
|
||||
<meta-data
|
||||
android:name="asset_statements"
|
||||
android:resource="@string/assetStatements" />
|
||||
|
||||
|
||||
<meta-data
|
||||
android:name="web_manifest_url"
|
||||
android:value="@string/webManifestUrl" />
|
||||
|
||||
|
||||
<meta-data
|
||||
android:name="twa_generator"
|
||||
android:value="@string/generatorApp" />
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<activity android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
|
||||
android:exported="false"
|
||||
android:enabled="true"
|
||||
android:excludeFromRecents="true">
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.MANAGE_SPACE_URL"
|
||||
android:value="@string/launchUrl" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<activity android:name="LauncherActivity"
|
||||
android:alwaysRetainTaskState="true"
|
||||
android:label="@string/launcherName"
|
||||
android:exported="true">
|
||||
<meta-data android:name="android.support.customtabs.trusted.DEFAULT_URL"
|
||||
android:value="@string/launchUrl" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
|
||||
android:resource="@color/colorPrimary" />
|
||||
|
||||
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR_DARK"
|
||||
android:resource="@color/colorPrimaryDark" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
|
||||
android:resource="@color/navigationColor" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
|
||||
android:resource="@color/navigationColorDark" />
|
||||
|
||||
<meta-data
|
||||
android:name="androix.browser.trusted.NAVIGATION_BAR_DIVIDER_COLOR"
|
||||
android:resource="@color/navigationDividerColor" />
|
||||
|
||||
<meta-data
|
||||
android:name="androix.browser.trusted.NAVIGATION_BAR_DIVIDER_COLOR_DARK"
|
||||
android:resource="@color/navigationDividerColorDark" />
|
||||
|
||||
<meta-data android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
|
||||
android:resource="@drawable/splash"/>
|
||||
|
||||
<meta-data android:name="android.support.customtabs.trusted.SPLASH_SCREEN_BACKGROUND_COLOR"
|
||||
android:resource="@color/backgroundColor"/>
|
||||
|
||||
<meta-data android:name="android.support.customtabs.trusted.SPLASH_SCREEN_FADE_OUT_DURATION"
|
||||
android:value="@integer/splashScreenFadeOutDuration"/>
|
||||
|
||||
<meta-data android:name="android.support.customtabs.trusted.FILE_PROVIDER_AUTHORITY"
|
||||
android:value="@string/providerAuthority"/>
|
||||
|
||||
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" />
|
||||
|
||||
<meta-data android:name="android.support.customtabs.trusted.FALLBACK_STRATEGY"
|
||||
android:value="@string/fallbackType" />
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<meta-data android:name="android.support.customtabs.trusted.SCREEN_ORIENTATION"
|
||||
android:value="@string/orientation"/>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"
|
||||
android:host="@string/hostName"
|
||||
|
||||
/>
|
||||
</intent-filter>
|
||||
|
||||
|
||||
|
||||
|
||||
</activity>
|
||||
|
||||
<activity android:name="com.google.androidbrowserhelper.trusted.FocusActivity" />
|
||||
|
||||
<activity android:name="com.google.androidbrowserhelper.trusted.WebViewFallbackActivity"
|
||||
android:configChanges="orientation|screenSize" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/providerAuthority"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/filepaths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".DelegationService"
|
||||
android:enabled="@bool/enableNotification"
|
||||
android:exported="@bool/enableNotification">
|
||||
|
||||
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
|
||||
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2020 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.imyeyu.gao.customer;
|
||||
|
||||
|
||||
|
||||
public class Application extends android.app.Application {
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.imyeyu.gao.customer;
|
||||
|
||||
|
||||
|
||||
public class DelegationService extends
|
||||
com.google.androidbrowserhelper.trusted.DelegationService {
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.imyeyu.gao.customer;
|
||||
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
|
||||
|
||||
public class LauncherActivity
|
||||
extends com.google.androidbrowserhelper.trusted.LauncherActivity {
|
||||
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
// Setting an orientation crashes the app due to the transparent background on Android 8.0
|
||||
// Oreo and below. We only set the orientation on Oreo and above. This only affects the
|
||||
// splash screen and Chrome will still respect the orientation.
|
||||
// See https://github.com/GoogleChromeLabs/bubblewrap/issues/496 for details.
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT);
|
||||
} else {
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Uri getLaunchingUrl() {
|
||||
// Get the original launch Url.
|
||||
Uri uri = super.getLaunchingUrl();
|
||||
|
||||
|
||||
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<!--
|
||||
Copyright 2020 Google Inc. All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:inset="2dp">
|
||||
<aapt:attr name="android:drawable">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="@color/shortcut_background" />
|
||||
<size android:width="44dp" android:height="44dp" />
|
||||
</shape>
|
||||
</aapt:attr>
|
||||
</inset>
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
Copyright 2019 Google Inc. All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt">
|
||||
<background>
|
||||
<layer-list>
|
||||
<item android:drawable="@android:color/white" />
|
||||
<!--
|
||||
The paddings below give the icons a similar proportion as the
|
||||
icon generated by WebAPKs.
|
||||
-->
|
||||
<item android:drawable="@mipmap/ic_maskable"
|
||||
android:top="8.5dp"
|
||||
android:right="8.5dp"
|
||||
android:left="8.5dp"
|
||||
android:bottom="8.5dp" />
|
||||
</layer-list>
|
||||
</background>
|
||||
<foreground android:drawable="@android:color/transparent" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "Gao Customer H5",
|
||||
"short_name": "Gao H5",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!--
|
||||
Copyright 2020 Google Inc. All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<resources>
|
||||
<color name="shortcut_background">#F5F5F5</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2021 Google Inc. All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<resources>
|
||||
|
||||
|
||||
<!--
|
||||
The "fullscreen" and "fullscreen-sticky" TWA manifest values correspond to "immersive" and "sticky-immersive"
|
||||
AndroidManifest values.
|
||||
-->
|
||||
|
||||
|
||||
<!--
|
||||
This variable below expresses the relationship between the app and the site,
|
||||
as documented in the TWA documentation at
|
||||
https://developers.google.com/web/updates/2017/10/using-twa#set_up_digital_asset_links_in_an_android_app
|
||||
and is injected into the AndroidManifest.xml
|
||||
-->
|
||||
<string name="assetStatements">
|
||||
[{
|
||||
\"relation\": [\"delegate_permission/common.handle_all_urls\"],
|
||||
\"target\": {
|
||||
\"namespace\": \"web\",
|
||||
\"site\": \"https://gao.imyeyu.com\"
|
||||
}
|
||||
}]
|
||||
|
||||
</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!--
|
||||
Copyright 2019 Google Inc. All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<paths>
|
||||
<files-path path="twa_splash/" name="twa_splash" />
|
||||
</paths>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
Copyright 2019 Google Inc. All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<shortcuts xmlns:android='http://schemas.android.com/apk/res/android' />
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.9.1'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1 @@
|
||||
dff2536399b4ac0116642fb1c752408b59929697
|
||||
@@ -0,0 +1 @@
|
||||
include ':app'
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"packageId": "com.imyeyu.gao.customer",
|
||||
"host": "gao.imyeyu.com",
|
||||
"name": "糕店客户管理",
|
||||
"launcherName": "糕店管理",
|
||||
"display": "standalone",
|
||||
"themeColor": "#FFFFFF",
|
||||
"themeColorDark": "#000000",
|
||||
"navigationColor": "#000000",
|
||||
"navigationColorDark": "#000000",
|
||||
"navigationDividerColor": "#000000",
|
||||
"navigationDividerColorDark": "#000000",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"enableNotifications": false,
|
||||
"startUrl": "/",
|
||||
"iconUrl": "https://gao.imyeyu.com/icons/icon-512.png",
|
||||
"maskableIconUrl": "https://gao.imyeyu.com/icons/icon-maskable-512.png",
|
||||
"splashScreenFadeOutDuration": 300,
|
||||
"signingKey": {
|
||||
"path": "./android.keystore",
|
||||
"alias": "gao-customer"
|
||||
},
|
||||
"appVersionName": "1",
|
||||
"appVersionCode": 1,
|
||||
"shortcuts": [],
|
||||
"generatorApp": "bubblewrap-cli",
|
||||
"webManifestUrl": "https://gao.imyeyu.com/manifest.webmanifest",
|
||||
"fallbackType": "customtabs",
|
||||
"features": {},
|
||||
"alphaDependencies": {
|
||||
"enabled": false
|
||||
},
|
||||
"enableSiteSettingsShortcut": true,
|
||||
"isChromeOSOnly": false,
|
||||
"isMetaQuest": false,
|
||||
"fullScopeUrl": "https://gao.imyeyu.com/",
|
||||
"minSdkVersion": 21,
|
||||
"orientation": "portrait",
|
||||
"fingerprints": [],
|
||||
"additionalTrustedOrigins": [],
|
||||
"retainedBundles": [],
|
||||
"protocolHandlers": [],
|
||||
"fileHandlers": [],
|
||||
"launchHandlerClientMode": "",
|
||||
"displayOverride": [],
|
||||
"appVersion": "1"
|
||||
}
|
||||