Compare commits

..
26 Commits
Author SHA1 Message Date
Timi 169c36734a fix help/about title 2026-09-15 11:46:46 +08:00
Timi 68c25790fd add help entry 2026-09-15 11:44:15 +08:00
Timi c0894cbab1 support PWA 2026-09-15 11:20:03 +08:00
Timi 8a0f5b6775 add point redemption 2026-09-15 10:44:29 +08:00
Timi f7d144e440 add help 2026-09-14 23:22:10 +08:00
Timi 6187f35a62 fix product title 2026-09-14 23:21:55 +08:00
Timi 0edb2c5565 do not reset event option when submitted 2026-09-14 15:55:09 +08:00
Timi 649144897b fix filter from order detail 2026-09-14 13:22:36 +08:00
Timi 6674a6690b add disabled style 2026-09-14 01:00:15 +08:00
Timi a87609261b add SalesOrderStraightThroughRequest paymentAmount 2026-09-14 00:47:47 +08:00
Timi 844879773f fix OrderFulfillmentCreate.vue style 2026-09-13 23:41:02 +08:00
Timi 34fed4702d fix store name overflow 2026-09-13 15:35:11 +08:00
Timi 9332f6ddb8 fix list style 2026-09-13 15:34:53 +08:00
Timi b841f202a7 fix package name overflow 2026-09-13 15:34:43 +08:00
Timi 822767ac64 default disabled auto submit 2026-09-13 15:00:00 +08:00
Timi f320717f84 fix ProductCategoryList.vue search style 2026-09-13 01:31:02 +08:00
Timi 6cce293683 auto complete order 2026-09-13 01:25:37 +08:00
Timi 8d9d0c4591 package zip 2026-09-13 00:07:37 +08:00
Timi f191729b58 update style 2026-09-13 00:07:29 +08:00
Timi 2080a88eb8 add UserAgreement.vue 2026-09-12 23:51:38 +08:00
Timi 45818de9f1 fix QuickSignInPage.vue style 2026-09-12 22:30:10 +08:00
Timi 584fbfadf5 signal event selector 2026-09-12 17:01:27 +08:00
Timi 194a55a03f update home tab 2026-09-12 16:21:05 +08:00
Timi cb4c19f9e4 auto submit when scan succeed 2026-09-12 15:58:36 +08:00
Timi b3c01545ab fix avatar radius 2026-09-12 15:30:01 +08:00
Timi f5b246a50e fix dropdown menu bottom line 2026-09-12 15:11:04 +08:00
84 changed files with 4681 additions and 285 deletions
+4
View File
@@ -6,6 +6,10 @@
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "pnpm run type-check:publish && vite build", "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", "preview": "vite preview",
"lint": "eslint . --ext .ts,.vue", "lint": "eslint . --ext .ts,.vue",
"type-check": "vue-tsc -p tsconfig.app.json --noEmit", "type-check": "vue-tsc -p tsconfig.app.json --noEmit",
+12
View File
@@ -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"
]
}
}
]
+4 -2
View File
@@ -1,11 +1,13 @@
{ {
"name": "Gao Customer H5", "id": "/",
"short_name": "Gao H5", "name": "糕店客户管理",
"short_name": "糕店管理",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#ffffff", "theme_color": "#ffffff",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"orientation": "portrait",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "/icons/icon-192.png",
+66 -2
View File
@@ -7,11 +7,20 @@ import type {
PointLedger, PointLedger,
PointLedgerPage, PointLedgerPage,
PointRule, PointRule,
GroovyScript GroovyScript,
PointRedeemSku,
PointRedeemSkuPage,
PointRedemption,
PointRedemptionCreateRequest,
PointRedemptionPage,
PointRedemptionReturn,
PointRedemptionReturnRequest
} from "@/types/Point"; } from "@/types/Point";
import { getCurrentStoreId, withCurrentStoreId, withCurrentStorePage } from "@/utils/store"; import { getCurrentStoreId, withCurrentStoreId, withCurrentStorePage } from "@/utils/store";
const BASE_URI = "/gao/customer/point"; const BASE_URI = "/gao/customer/point";
const REDEEM_SKU_URI = "/gao/point/redeem-sku";
const REDEMPTION_URI = "/gao/point/redemption";
type ListOptions = { type ListOptions = {
allStores?: boolean; 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 { export default {
list, list,
detailByCustomerId, detailByCustomerId,
@@ -130,5 +183,16 @@ export default {
ruleCreate, ruleCreate,
ruleUpdate, ruleUpdate,
ruleSort, ruleSort,
ruleRemove ruleRemove,
redeemSkuList,
redeemSkuDetail,
redeemSkuCreate,
redeemSkuUpdate,
redeemSkuDisable,
redeemSkuRemove,
redemptionList,
redemptionDetail,
redemptionCreate,
redemptionReturn,
redemptionReturnList
}; };
+11 -2
View File
@@ -95,7 +95,7 @@ html.theme-dark body {
.t-dropdown-menu { .t-dropdown-menu {
&:after { &:after {
display: none; display: none !important;
} }
:deep(.t-dropdown-menu__item) { :deep(.t-dropdown-menu__item) {
@@ -119,7 +119,7 @@ html.theme-dark body {
.avatar { .avatar {
flex: none; flex: none;
display: block; display: block;
border-radius: .875rem; border-radius: .875rem !important;
&.fallback { &.fallback {
color: #2563EB; color: #2563EB;
@@ -143,6 +143,15 @@ html.theme-dark body {
display: none; display: none;
} }
.fix-cell-title-clip {
.t-cell__title {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
}
}
.entrance-fade-up-enter-active { .entrance-fade-up-enter-active {
transition: opacity .4s ease, transform .4s ease; transition: opacity .4s ease, transform .4s ease;
transition-delay: var(--entrance-delay, 0ms); transition-delay: var(--entrance-delay, 0ms);
-7
View File
@@ -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) { :global(html.theme-dark) {
.customer-cell { .customer-cell {
.code { .code {
+1 -17
View File
@@ -32,7 +32,7 @@
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
<div v-else class="empty-block" v-text="emptyText" /> <div v-else class="center-tips" v-text="emptyText" />
</div> </div>
</template> </template>
@@ -115,22 +115,6 @@ function handleRemove(customer: Customer): void {
width: 3.5rem; width: 3.5rem;
height: 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) { :global(html.theme-dark) {
+1 -2
View File
@@ -292,7 +292,6 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
} }
.sku-card { .sku-card {
margin-top: 1.25rem;
padding: .75rem; padding: .75rem;
background: var(--td-bg-color-secondarycontainer); background: var(--td-bg-color-secondarycontainer);
border-radius: .875rem; border-radius: .875rem;
@@ -349,7 +348,7 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
padding-bottom: env(safe-area-inset-bottom, 0); padding-bottom: env(safe-area-inset-bottom, 0);
.collapse { .collapse {
margin-bottom: 1rem; margin: 0;
} }
.product-header { .product-header {
+16
View File
@@ -10,6 +10,8 @@ import { VCan, VRole } from "timi-web-api/utils/directives/Access";
import type { ApiResponse } from "timi-web-api"; import type { ApiResponse } from "timi-web-api";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import vEntranceTransition from "@/directives/entranceTransition"; 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 "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import "tdesign-mobile-vue/es/style/index.css"; import "tdesign-mobile-vue/es/style/index.css";
@@ -20,6 +22,17 @@ import "@/assets/styles/global.less";
export const pinia = createPinia(); export const pinia = createPinia();
const apiBaseURL = import.meta.env.VITE_API_BASE_URL as string | undefined; 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) { if (apiBaseURL) {
Network.axios.defaults.baseURL = apiBaseURL; Network.axios.defaults.baseURL = apiBaseURL;
@@ -66,7 +79,10 @@ async function bootstrap(): Promise<void> {
app.use(router); app.use(router);
await router.isReady(); await router.isReady();
await import("tdesign-mobile-vue");
await import("../../timi-web/src/index");
await import("timi-tdesign-mobile"); await import("timi-tdesign-mobile");
mountLibraryOverrideStyle();
app.mount("#root"); app.mount("#root");
} }
+42 -9
View File
@@ -54,27 +54,33 @@
/> />
<t-button size="extra-small" variant="text" @click="clearTagFilter">清空</t-button> <t-button size="extra-small" variant="text" @click="clearTagFilter">清空</t-button>
</div> </div>
<t-cell-group v-if="dateFilterType !== 'all'" class="date-summary" theme="card"> <t-pull-down-refresh
class="fix-last-cell-line"
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<div class="list-wrap">
<t-cell-group class="summary" theme="card">
<t-cell <t-cell
v-if="dateFilterType !== 'all'"
title="开始时间" title="开始时间"
:note="dateRange[0] || '-'" :note="dateRange[0] || '-'"
arrow arrow
@click="openStartTime" @click="openStartTime"
/> />
<t-cell <t-cell
v-if="dateFilterType !== 'all'"
title="结束时间" title="结束时间"
:note="dateRange[1] || '-'" :note="dateRange[1] || '-'"
arrow arrow
@click="openEndTime" @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> </t-cell-group>
<t-pull-down-refresh
class="list-wrapper fix-last-cell-line"
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<t-cell-group class="items" theme="card"> <t-cell-group class="items" theme="card">
<t-swipe-cell <t-swipe-cell
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
@@ -106,6 +112,7 @@
</t-cell-group> </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 ? '正在加载客户' : '暂无客户数据'" />
<div v-if="pageResult.list.length" class="center-tips" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" /> <div v-if="pageResult.list.length" class="center-tips" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
</div>
</t-pull-down-refresh> </t-pull-down-refresh>
<tag-selector <tag-selector
v-model="selectedTagIdList" 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; 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 { .delete {
height: 100%; height: 100%;
padding: 0 1rem; 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 { .items {
.center-tips, .center-tips,
@@ -25,9 +25,19 @@
<div class="main"> <div class="main">
<div class="title"> <div class="title">
<div class="heading"> <div class="heading">
<strong v-text="item.productNameSnapshot || '未命名产品'" /> <div class="name-gift">
<t-tag v-if="item.gifted" size="small" theme="success" content="赠送" /> <strong
<span v-text="getItemDescription(item)" /> 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>
<div <div
v-if="item.skuCodeSnapshot || item.skuSpecSnapshot?.length || item.skuNameSnapshot || item.specSummarySnapshot || item.unitSnapshot" v-if="item.skuCodeSnapshot || item.skuSpecSnapshot?.length || item.skuNameSnapshot || item.specSummarySnapshot || item.unitSnapshot"
@@ -427,9 +437,18 @@ onUnmounted(() => navBarStore.clearRight());
.item { .item {
padding: 10px; padding: 10px;
min-width: 0;
:deep(.t-form__controls),
:deep(.t-form__controls-content) {
min-width: 0;
}
.content { .content {
gap: .5rem; gap: .5rem;
width: 100%;
min-width: 0;
flex: 1 1 auto;
display: flex; display: flex;
.photo { .photo {
@@ -444,12 +463,14 @@ onUnmounted(() => navBarStore.clearRight());
.main { .main {
flex: 1; flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
.title { .title {
gap: .375rem; gap: .375rem;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -460,14 +481,23 @@ onUnmounted(() => navBarStore.clearRight());
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
.name-gift {
gap: .5rem;
flex: 1 1 0;
min-width: 0;
display: flex;
overflow: hidden;
strong { strong {
flex: 1 1 0;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
}
span { .can-fulfill {
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
font-size: .75rem; font-size: .75rem;
white-space: nowrap; white-space: nowrap;
@@ -53,6 +53,19 @@
@scrolltolower="loadMore" @scrolltolower="loadMore"
> >
<div class="list-wrap"> <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-group v-if="isCustomDate" class="date-summary" theme="card">
<t-cell <t-cell
title="开始时间" title="开始时间"
@@ -120,6 +133,7 @@ import { Time } from "timi-web";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import FulfillmentAPI from "@/api/FulfillmentAPI"; import FulfillmentAPI from "@/api/FulfillmentAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import CustomerSelector from "@/components/CustomerSelector.vue"; import CustomerSelector from "@/components/CustomerSelector.vue";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue"; import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import OrderFulfillmentListCell from "@/pages/fulfillment/components/OrderFulfillmentListCell.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 { GaoFulfillmentStatus, OrderFulfillment } from "@/types/Fulfillment";
import type { NavBarAction } from "@/types/NavBarAction"; import type { NavBarAction } from "@/types/NavBarAction";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { SalesOrder } from "@/types/SalesOrder";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { getSalesOrderStatusText, getSalesOrderTypeText, getStatusTheme } from "@/utils/salesOrder";
defineOptions({ name: "OrderFulfillmentList" }); defineOptions({ name: "OrderFulfillmentList" });
@@ -167,6 +183,7 @@ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
const customerId = ref(getQueryText(route.query.customerId)); const customerId = ref(getQueryText(route.query.customerId));
const selectedCustomer = ref<Customer>(); const selectedCustomer = ref<Customer>();
const salesOrder = ref<SalesOrder>();
const customerSelectorVisible = ref(false); const customerSelectorVisible = ref(false);
const customerSearchKeyword = ref(""); const customerSearchKeyword = ref("");
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType)); const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
@@ -331,6 +348,20 @@ async function clearCustomer(): Promise<void> {
await reloadList(); 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> { async function selectDateFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return; return;
@@ -513,6 +544,7 @@ onMounted(async () => {
navBarStore.clearRight(); navBarStore.clearRight();
navBarStore.setCustomRightVisible(canCreate.value); navBarStore.setCustomRightVisible(canCreate.value);
try { try {
await loadSalesOrder();
if (customerId.value) { if (customerId.value) {
selectedCustomer.value = await CustomerAPI.detail(customerId.value); selectedCustomer.value = await CustomerAPI.detail(customerId.value);
customerSearchKeyword.value = getCustomerSearchKeyword(selectedCustomer.value); customerSearchKeyword.value = getCustomerSearchKeyword(selectedCustomer.value);
+90 -55
View File
@@ -1,26 +1,36 @@
<template> <template>
<div v-can="permissionCodes.eventRecord.create" class="quick-sign-in-page"> <div v-can="permissionCodes.eventRecord.create" class="quick-sign-in-page">
<space class="content-margin" direction="vertical" v-entrance-transition> <t-cell-group v-if="!isEventLoading && !eventLoadFailed" theme="card">
<div class="event-panel"> <t-cell title="登记事件" arrow @click="eventPopupVisible = true">
<event-selector <template #left-icon>
v-if="!isEventLoading && !eventLoadFailed" <t-icon name="task-setting" />
:model-value="selectedEventIdList" </template>
:event-list="eventList" <template #note>
required-mark <span class="red text-bold" v-text="selectedEventName" />
@update:model-value="handleEventSelectionChange" </template>
/> </t-cell>
<div v-else-if="isEventLoading" class="empty-block">正在加载登记事件</div> <t-cell title="识别成功自动登记">
<div v-else class="event-load-error"> <template #left-icon>
<span>登记事件加载失败</span> <t-icon name="task-checked" />
<t-button size="small" theme="primary" variant="text" @click="loadActions">重试</t-button> </template>
</div> <template #right-icon>
<div class="tips red">事件可多选,请注意勾选的登记事件</div> <t-switch v-model="autoRegisterEnabled" />
</div> </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 <event-option-selector
v-model="selectedOptionSelectionMap" v-model="selectedOptionSelectionMap"
:event-list="eventList" :event-list="eventList"
:selected-event-id-list="selectedEventIdList" :selected-event-id-list="selectedEventIdList"
/> />
</div>
<div class="content-margin">
<code-scanner <code-scanner
visible visible
:session-key="scannerSessionKey" :session-key="scannerSessionKey"
@@ -29,14 +39,14 @@
@decode="handleDecodedText" @decode="handleDecodedText"
@status-change="handleScanStatusChange" @status-change="handleScanStatusChange"
/> />
</space> </div>
<t-button <t-button
class="cell-button" class="cell-button"
theme="primary" theme="primary"
:disabled="!canSubmitRegister" :disabled="!canSubmitRegister"
:loading="submitting" :loading="submitting"
v-entrance-transition="70" v-entrance-transition="70"
@click="doSubmit" @click="doSubmit()"
>登记</t-button> >登记</t-button>
<customer-profile-card <customer-profile-card
v-if="matchedCustomer" v-if="matchedCustomer"
@@ -110,6 +120,29 @@
@change="handleRecordPhotoSelected" @change="handleRecordPhotoSelected"
> >
</div> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -133,9 +166,9 @@ import { permissionCodes } from "@/utils/permission";
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord"; import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
import { compressUploadImageList } from "@/utils/imageCompression"; import { compressUploadImageList } from "@/utils/imageCompression";
import { Storage, Time } from "timi-web"; 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 EventSelector from "@/components/EventSelector.vue";
import { Space } from "timi-tdesign-mobile"; import { BottomPopup } from "timi-tdesign-mobile";
defineOptions({ defineOptions({
name: "QuickSignInPage" name: "QuickSignInPage"
@@ -147,14 +180,17 @@ const settingStore = useSettingStore();
// ---------- 登记事件 ---------- // ---------- 登记事件 ----------
const EventItemStorageKey = "quick-scan-event-id"; const EventItemStorageKey = "quick-scan-event-id";
const autoRegisterEnabled = ref(false);
const selectedEventIdList = ref<string[]>([]); const selectedEventIdList = ref<string[]>([]);
const eventList = ref<EventItem[]>([]); const eventList = ref<EventItem[]>([]);
const selectedOptionSelectionMap = reactive<EventOptionSelectionMap>({}); const selectedOptionSelectionMap = reactive<EventOptionSelectionMap>({});
const isEventLoading = ref(false); const isEventLoading = ref(false);
const eventLoadFailed = ref(false); const eventLoadFailed = ref(false);
const eventPopupVisible = ref(false);
const selectedEventList = computed(() => selectedEventIdList.value const selectedEventList = computed(() => selectedEventIdList.value
.map(eventId => eventList.value.find(item => normalizeEventId(item) === eventId)) .map(eventId => eventList.value.find(item => normalizeEventId(item) === eventId))
.filter((item): item is EventItem => !!item)); .filter((item): item is EventItem => !!item));
const selectedEventName = computed(() => selectedEventList.value[0]?.name || "请选择登记事件");
const selectedNumberEventList = computed(() => selectedEventList.value.filter(item => item.valueType === "NUMBER")); const selectedNumberEventList = computed(() => selectedEventList.value.filter(item => item.valueType === "NUMBER"));
const selectedRequiredPhotoEventList = computed(() => selectedEventList.value.filter(item => item.requirePhoto === true)); const selectedRequiredPhotoEventList = computed(() => selectedEventList.value.filter(item => item.requirePhoto === true));
@@ -200,12 +236,13 @@ function buildInitialSelectedEventIdList(): string[] {
function normalizeSelectedEventIdList(eventIdList: string[]): string[] { function normalizeSelectedEventIdList(eventIdList: string[]): string[] {
const validEventIdSet = new Set(eventList.value.map(normalizeEventId).filter(Boolean)); 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> { async function handleEventSelectionChange(eventIdList: string[]): Promise<void> {
selectedEventIdList.value = normalizeSelectedEventIdList(eventIdList); selectedEventIdList.value = normalizeSelectedEventIdList(eventIdList);
syncStoredEventId(); syncStoredEventId();
eventPopupVisible.value = false;
} }
function resolveRecordOptionGroupList(eventId: string) { function resolveRecordOptionGroupList(eventId: string) {
@@ -243,6 +280,7 @@ const scanCode = ref("");
const scanStatus = ref("正在启动扫码预览"); const scanStatus = ref("正在启动扫码预览");
const scannerSessionKey = ref(0); const scannerSessionKey = ref(0);
const matchedCustomer = ref<Customer>(); const matchedCustomer = ref<Customer>();
const registeredCustomerIdSet = new Set<string>();
let latestDecodedText = ""; let latestDecodedText = "";
async function handleDecodedText(rawText: string): Promise<void> { async function handleDecodedText(rawText: string): Promise<void> {
@@ -254,8 +292,16 @@ async function handleDecodedText(rawText: string): Promise<void> {
scanCode.value = normalizedText; scanCode.value = normalizedText;
await settingStore.playScanSuccessSound(); await settingStore.playScanSuccessSound();
await loadCustomer(normalizedText); 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},可直接登记`; scanStatus.value = `已识别 ${normalizedText},可直接登记`;
if (autoRegisterEnabled.value) {
await doSubmit(true);
}
return; return;
} }
scanStatus.value = `已识别 ${normalizedText},但未匹配客户档案`; 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; const customer = matchedCustomer.value;
if (!customer) { if (!customer) {
return; return;
} }
const customerId = customer.id;
if (!customerId) {
return;
}
if (registeredCustomerIdSet.has(customerId)) {
Message.warning("该客户已在本页面登记,请勿重复登记");
return;
}
if (!selectedEventIdList.value.length) { if (!selectedEventIdList.value.length) {
Message.warning("请至少选择一个登记事件"); Message.warning("请至少选择一个登记事件");
return; return;
@@ -425,22 +479,29 @@ async function doSubmit(): Promise<void> {
try { try {
await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({ await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({
eventId, eventId,
customerId: customer.id, customerId,
selectedOptionGroupList: resolveRecordOptionGroupList(eventId), selectedOptionGroupList: resolveRecordOptionGroupList(eventId),
numberValue: resolveNumberValue(eventId), numberValue: resolveNumberValue(eventId),
remark: "客户快速扫码登记", remark: "客户快速扫码登记",
attachmentList: buildPhotoAttachmentList() 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 = ""; scanCode.value = "";
matchedCustomer.value = undefined; matchedCustomer.value = undefined;
recordPhotoItemList.value = []; recordPhotoItemList.value = [];
for (const eventId of Object.keys(numberValueTextMap)) { for (const eventId of Object.keys(numberValueTextMap)) {
delete numberValueTextMap[eventId]; delete numberValueTextMap[eventId];
} }
if (!isAutoRegister) {
for (const eventId of Object.keys(selectedOptionSelectionMap)) { for (const eventId of Object.keys(selectedOptionSelectionMap)) {
delete selectedOptionSelectionMap[eventId]; delete selectedOptionSelectionMap[eventId];
} }
}
recentRecordList.value = []; recentRecordList.value = [];
latestDecodedText = ""; latestDecodedText = "";
scannerSessionKey.value += 1; scannerSessionKey.value += 1;
@@ -459,18 +520,6 @@ async function doSubmit(): Promise<void> {
display: flex; display: flex;
flex-direction: column; 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 { .input-form {
padding: .5rem 0; padding: .5rem 0;
background: rgba(255, 255, 255, .94); background: rgba(255, 255, 255, .94);
@@ -492,24 +541,6 @@ async function doSubmit(): Promise<void> {
display: none; 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 { .code-box {
padding: .875rem; padding: .875rem;
background: #f7faff; background: #f7faff;
@@ -547,6 +578,10 @@ async function doSubmit(): Promise<void> {
} }
} }
.event-popup {
padding: 1rem;
}
:global(html.theme-dark) { :global(html.theme-dark) {
.quick-sign-in-page { .quick-sign-in-page {
.panel, .panel,
+223 -25
View File
@@ -65,7 +65,7 @@
<div class="description" v-text="config.description" /> <div class="description" v-text="config.description" />
</div> </div>
</t-form-item> </t-form-item>
<t-form-item label="成交价(元)" label-width="6rem" required-mark> <t-form-item label="成交价" label-width="6rem" required-mark>
<t-input <t-input
v-model.trim="saleAmountText" v-model.trim="saleAmountText"
type="number" type="number"
@@ -170,6 +170,59 @@
/> />
</div> </div>
</t-form-item> </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-form-item label="购买备注">
<t-textarea <t-textarea
v-model.trim="note" v-model.trim="note"
@@ -195,26 +248,48 @@
:enable-scan="false" :enable-scan="false"
@change="handleCustomerChange" @change="handleCustomerChange"
/> />
<t-popup v-model="configSelectorVisible" placement="bottom"> <bottom-popup
<div class="config-selector bg-white"> v-model="configSelectorVisible"
<div class="popup-header"> title="选择套餐"
<t-button variant="text" size="small" @click="configSelectorVisible = false">关闭</t-button> height="70vh"
<h3>选择套餐</h3> right-button-text=""
<span /> @left-button-click="configSelectorVisible = false"
</div> >
<div class="config-selector fix-cell-title-clip empty-cell-title-margin">
<t-cell-group v-if="configList.length"> <t-cell-group v-if="configList.length">
<t-cell <t-cell
v-for="item in configList" v-for="item in configList"
:key="item.id" :key="item.id"
:title="item.name || '未命名套餐'" class="config-option"
:note="`标准价:${formatPackageAmount(item.standardAmount)}`"
arrow arrow
@click="selectConfig(item)" @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> </t-cell-group>
<div v-else class="center-tips" v-text="'暂无可购买套餐'" /> <div v-else class="center-tips" v-text="'暂无可购买套餐'" />
</div> </div>
</t-popup> </bottom-popup>
<t-popup v-model="unitPickerVisible" placement="bottom"> <t-popup v-model="unitPickerVisible" placement="bottom">
<t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="调整权益单位" @cancel="unitPickerVisible = false" @confirm="confirmItemUnitPicker" /> <t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="调整权益单位" @cancel="unitPickerVisible = false" @confirm="confirmItemUnitPicker" />
</t-popup> </t-popup>
@@ -252,6 +327,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Time } from "timi-web"; import { Time } from "timi-web";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import { BottomPopup } from "timi-tdesign-mobile";
import { DialogPlugin } from "tdesign-mobile-vue";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import PackageAPI from "@/api/PackageAPI"; import PackageAPI from "@/api/PackageAPI";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
@@ -265,14 +342,16 @@ import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore"; import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { PhotoItem } from "@/types/Photo"; 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 type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { import {
addPackageQuantity, addPackageQuantity,
convertPackageQuantity, convertPackageQuantity,
formatPackageAmount, formatPackageAmount,
normalizePackageQuantity getPackageQuantityText,
normalizePackageQuantity,
parsePackageAmount
} from "@/utils/package"; } from "@/utils/package";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
@@ -423,10 +502,21 @@ function parseDateTime(value: string): Date {
const note = ref(""); const note = ref("");
const isSaving = ref(false); const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]); 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 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[] { function buildAttachments(source: PhotoItem[] = photoItems.value): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ return source.flatMap(item => item.tempFileId ? [{
tempFileId: item.tempFileId, tempFileId: item.tempFileId,
attachType: item.attachType || "PHOTO" attachType: item.attachType || "PHOTO"
}] : []); }] : []);
@@ -446,9 +536,35 @@ const {
config, config,
selectedCustomerList, selectedCustomerList,
note, note,
paymentVerified,
paymentAmount: parsedPaymentAmount,
receiveFullPayment,
fulfillAll,
hasPermission: permissionCode => userStore.hasPermissions(permissionCode), hasPermission: permissionCode => userStore.hasPermissions(permissionCode),
buildRegisteredAt: buildPurchasedAt, 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(() => { const unitPickerOptions = computed(() => {
@@ -456,6 +572,12 @@ const unitPickerOptions = computed(() => {
return item ? getSelectableUnitList(item).map(unit => ({ label: unit.name, value: unit.id || "" })) : []; 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 { function getItemLabel(item: PackagePurchaseItem): string {
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId); const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId);
const product = item.gifted ? giftProductMap[item.skuId] : undefined; const product = item.gifted ? giftProductMap[item.skuId] : undefined;
@@ -647,6 +769,30 @@ async function submitRecord(): Promise<void> {
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) { if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
return; 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; isSaving.value = true;
try { try {
@@ -656,7 +802,7 @@ async function submitRecord(): Promise<void> {
return; return;
} }
await settingStore.playRegisterSuccessSound(); await settingStore.playRegisterSuccessSound();
Message.success("套餐购买已提交"); Message.success(fulfillAll.value ? "套餐、收款及履约已完成" : paymentVerified.value ? "套餐及已核销收款已完成" : receiveFullPayment.value ? "套餐及全额收款已完成" : "套餐购买已提交");
await router.replace({ await router.replace({
name: "SalesOrderDetail", name: "SalesOrderDetail",
params: { id: result.salesOrderId }, params: { id: result.salesOrderId },
@@ -749,6 +895,25 @@ watch(config, value => {
width: 100%; 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 { .customer-profile {
margin: 1rem; margin: 1rem;
} }
@@ -809,15 +974,14 @@ watch(config, value => {
} }
.item { .item {
padding: 10px; padding: 6px;
box-shadow: .25rem .25rem 1rem rgba(0, 0, 0, .08); box-shadow: .25rem .25rem 1rem rgba(0, 0, 0, .08);
border-radius: 1rem; border-radius: 1rem;
.avatar { .avatar {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
flex: none; border-radius: .5rem !important;
border-radius: .5rem;
} }
&::after { &::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 { .time-actions {
@@ -874,14 +1046,40 @@ watch(config, value => {
} }
} }
:deep(.package-purchase-form .avatar .t-image__img) { .config-selector {
width: 100%;
height: 100%; height: 100%;
object-fit: cover; overflow: auto;
border-radius: inherit;
.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 { .selected-customer-popup {
height: 70vh; height: 70vh;
overflow: auto; overflow: auto;
+49 -1
View File
@@ -10,9 +10,15 @@ interface UsePackagePurchaseOptions {
config: Ref<GaoPackageConfig | undefined>; config: Ref<GaoPackageConfig | undefined>;
selectedCustomerList: Ref<Customer[]>; selectedCustomerList: Ref<Customer[]>;
note: Ref<string>; note: Ref<string>;
paymentVerified: Ref<boolean>;
paymentAmount: Ref<number | undefined>;
receiveFullPayment: Ref<boolean>;
fulfillAll: Ref<boolean>;
hasPermission: (permissionCode: string) => boolean; hasPermission: (permissionCode: string) => boolean;
buildRegisteredAt: () => number; buildRegisteredAt: () => number;
buildAttachments: () => PackageAttachment[]; buildAttachments: () => PackageAttachment[];
buildPaymentAttachments: () => PackageAttachment[];
buildFulfillmentAttachments: () => PackageAttachment[];
} }
/** 套餐配置直接购买流程,购买请求只依赖套餐配置 ID,不再关联购买事件。 */ /** 套餐配置直接购买流程,购买请求只依赖套餐配置 ID,不再关联购买事件。 */
@@ -91,12 +97,21 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
saleAmount, saleAmount,
purchasedAt: options.buildRegisteredAt(), purchasedAt: options.buildRegisteredAt(),
attachmentList: options.buildAttachments(), 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, itemList,
remark: options.note.value.trim() || null remark: options.note.value.trim() || null
}); });
} }
function validatePurchaseForm(): boolean { function validatePurchaseForm(): boolean {
const saleAmount = parsePackageAmount(saleAmountText.value);
if (!options.hasPermission(permissionCodes.customerPackage.create)) { if (!options.hasPermission(permissionCodes.customerPackage.create)) {
Message.warning("当前账号没有套餐购买权限"); Message.warning("当前账号没有套餐购买权限");
return false; return false;
@@ -113,7 +128,7 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
Message.warning("套餐当前不可购买,请检查套餐状态和有效期"); Message.warning("套餐当前不可购买,请检查套餐状态和有效期");
return false; return false;
} }
if (parsePackageAmount(saleAmountText.value) === undefined) { if (saleAmount === undefined) {
Message.warning("成交价必须是合法的元金额,最多两位小数"); Message.warning("成交价必须是合法的元金额,最多两位小数");
return false; return false;
} }
@@ -139,6 +154,39 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
Message.warning("套餐至少需要保留一项正数量权益"); Message.warning("套餐至少需要保留一项正数量权益");
return false; 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; return true;
} }
+8 -9
View File
@@ -19,12 +19,12 @@
</t-input> </t-input>
</div> </div>
<t-pull-down-refresh <t-pull-down-refresh
class="list-wrapper"
v-model="isRefreshing" v-model="isRefreshing"
:refresh-timeout="30000" :refresh-timeout="30000"
@refresh="reload" @refresh="reload"
@scrolltolower="load" @scrolltolower="load"
> >
<div class="list-wrap">
<t-cell-group v-if="pageResult.list.length" class="list" theme="card"> <t-cell-group v-if="pageResult.list.length" class="list" theme="card">
<customer-cell <customer-cell
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
@@ -46,6 +46,7 @@
</customer-cell> </customer-cell>
</t-cell-group> </t-cell-group>
<div class="center-tips" v-text="loadStateText" /> <div class="center-tips" v-text="loadStateText" />
</div>
</t-pull-down-refresh> </t-pull-down-refresh>
<customer-selector <customer-selector
v-model="selectedCustomerId" v-model="selectedCustomerId"
@@ -271,10 +272,15 @@ onMounted(async () => {
} }
} }
.list-wrapper { .list-wrap {
gap: 1rem;
display: flex;
min-height: 70vh; min-height: 70vh;
padding-top: 1rem;
flex-direction: column;
.list { .list {
.item { .item {
padding: 10px; padding: 10px;
@@ -300,13 +306,6 @@ onMounted(async () => {
} }
} }
} }
.center-tips {
color: #52607a;
padding: 1rem;
font-size: .8125rem;
text-align: center;
}
} }
:global(html.theme-dark) { :global(html.theme-dark) {
+225
View File
@@ -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>
+134
View File
@@ -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>
+363
View File
@@ -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>
+240
View File
@@ -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>
+574
View File
@@ -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>
+4 -1
View File
@@ -372,15 +372,18 @@ onUnmounted(() => {
box-shadow: var(--td-shadow-1); box-shadow: var(--td-shadow-1);
.keyword { .keyword {
--td-input-vertical-padding: 8px 14px;
flex: 1; flex: 1;
border: 1px solid var(--td-component-border); border: 1px solid var(--td-component-border);
z-index: 1; z-index: 1;
padding: .5rem 1rem;
background: var(--td-bg-color-container); background: var(--td-bg-color-container);
border-radius: 1rem; border-radius: 1rem;
} }
.status { .status {
--td-spacer-1: 6px;
margin-left: -2rem; margin-left: -2rem;
padding-left: 1.5rem; padding-left: 1.5rem;
border-radius: 1rem; border-radius: 1rem;
+135 -3
View File
@@ -136,6 +136,59 @@
/> />
</div> </div>
</t-form-item> </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-form-item label="订单备注">
<t-textarea v-model="remark" class="textarea" borderless autosize maxlength="500" placeholder="记录本单需要注意的内容" /> <t-textarea v-model="remark" class="textarea" borderless autosize maxlength="500" placeholder="记录本单需要注意的内容" />
</t-form-item> </t-form-item>
@@ -169,6 +222,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Attachment, useUserStore } from "timi-web-api"; import { type Attachment, useUserStore } from "timi-web-api";
import { Time } from "timi-web"; import { Time } from "timi-web";
import { DialogPlugin } from "tdesign-mobile-vue";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI"; import SalesOrderAPI from "@/api/SalesOrderAPI";
@@ -206,7 +260,18 @@ const draftTime = ref("");
const pickerVisible = ref(false); const pickerVisible = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]); 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 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 maxQuantity = Number.MAX_SAFE_INTEGER;
const productCache = new Map<string, Product>(); const productCache = new Map<string, Product>();
const skuCache = new Map<string, ProductSku>(); const skuCache = new Map<string, ProductSku>();
@@ -218,6 +283,26 @@ const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。"); const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = ""; 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 { function getSku(item: FormItem): ProductSku | undefined {
return skuCache.get(item.skuId || ""); return skuCache.get(item.skuId || "");
} }
@@ -253,6 +338,7 @@ const totalAmount = computed(() => {
return amount; return amount;
}); });
const totalAmountText = computed(() => totalAmount.value === undefined ? "0.00" : formatProductAmount(totalAmount.value)); const totalAmountText = computed(() => totalAmount.value === undefined ? "0.00" : formatProductAmount(totalAmount.value));
const paymentAmountPlaceholder = computed(() => `订单总金额:${formatProductAmount(totalAmount.value || 0)}`);
function createItem(): FormItem { function createItem(): FormItem {
return { return {
@@ -376,8 +462,8 @@ function resolveTimestamp(value: string): number | undefined {
const timestamp = new Date(value.replace(" ", "T")).getTime(); const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined; return Number.isFinite(timestamp) ? timestamp : undefined;
} }
function buildAttachments(): Partial<Attachment>[] { function buildAttachments(source: PhotoItem[] = photoItems.value): Partial<Attachment>[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []); return source.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
} }
function validate(): boolean { function validate(): boolean {
if (!customer.value?.id) { Message.warning("请选择客户"); return false; } if (!customer.value?.id) { Message.warning("请选择客户"); return false; }
@@ -392,10 +478,40 @@ function validate(): boolean {
keys.add(key); keys.add(key);
if (canPriceOverride.value && item.unitPriceText && parsePackageAmount(item.unitPriceText) === undefined) { Message.warning("成交单价必须是合法金额"); return false; } 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; return true;
} }
async function save(): Promise<void> { async function save(): Promise<void> {
if (isSaving.value || !validate()) return; 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(); if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true; isSaving.value = true;
try { try {
@@ -413,9 +529,17 @@ async function save(): Promise<void> {
purchasedAt: resolveTimestamp(purchasedAtText.value), purchasedAt: resolveTimestamp(purchasedAtText.value),
itemList, itemList,
attachmentList: buildAttachments(), 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 remark: remark.value.trim() || null
}); });
Message.success("销售订单已创建"); Message.success(fulfillAll.value ? "订单、收款及履约已完成" : paymentVerified.value ? "订单及已核销收款已完成" : receiveFullPayment.value ? "订单及全额收款已完成" : "销售订单已创建");
await router.replace(`/management/sales-orders/${result.id}`); await router.replace(`/management/sales-orders/${result.id}`);
} catch (error) { } catch (error) {
const message = getErrorMessage(error, "销售订单创建失败,重试会复用同一请求号"); const message = getErrorMessage(error, "销售订单创建失败,重试会复用同一请求号");
@@ -468,6 +592,14 @@ onUnmounted(() => navBarStore.clearRight());
justify-content: flex-end; 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 { .product-part {
> .header { > .header {
+2 -1
View File
@@ -10,7 +10,7 @@
<t-tag :content="getSalesOrderStatusText(order.status)" variant="light-outline" :theme="getStatusTheme(order.status)" /> <t-tag :content="getSalesOrderStatusText(order.status)" variant="light-outline" :theme="getStatusTheme(order.status)" />
</div> </div>
</div> </div>
<h1 class="no" v-text="order.orderNo" /> <h1 class="no selectable" v-text="order.orderNo" />
<div class="meta"> <div class="meta">
<pass-time-label class="time" :timestamp="order.purchasedAt" underline /> <pass-time-label class="time" :timestamp="order.purchasedAt" underline />
<div class="amount red text-bold"> <div class="amount red text-bold">
@@ -455,6 +455,7 @@ async function toFulfillmentList(): Promise<void> {
path: "/management/order-fulfillments", path: "/management/order-fulfillments",
query: { query: {
salesOrderId: order.value.id, salesOrderId: order.value.id,
dateType: "all",
transitionDepth: String(getCurrentRouteDepth() + 1) transitionDepth: String(getCurrentRouteDepth() + 1)
} }
}); });
@@ -1,5 +1,5 @@
<template> <template>
<div class="sales-order-cell bg-white"> <div class="sales-order-cell" :class="{ cancelled: isCancelled }">
<div class="header"> <div class="header">
<div class="left"> <div class="left">
<t-image <t-image
@@ -172,10 +172,11 @@ const photoURL = computed(() => getCustomerPhotoURL(props.order.customer, 64));
const orderNoText = computed(() => `订单号:${props.order.orderNo || props.order.id || "未知"}`); const orderNoText = computed(() => `订单号:${props.order.orderNo || props.order.id || "未知"}`);
const totalProductQuantity = computed(() => sumProductQuantity("quantity")); const totalProductQuantity = computed(() => sumProductQuantity("quantity"));
const productSummaryText = computed(() => `共 ${itemList.value.length} 项 · 订单数量 ${totalProductQuantity.value}`); 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 showRefundActions = computed(() => props.canRefund && isCancelledPendingRefund.value);
const showMoreActionMenu = computed(() => props.canCancel || showRefundActions.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 canCancelOrder = computed(() => props.order.status === "ACTIVE");
const canFulfillByPayment = computed(() => props.order.paymentStatus === "PAID" || props.canStoreManager); const canFulfillByPayment = computed(() => props.order.paymentStatus === "PAID" || props.canStoreManager);
const showFulfillAction = computed(() => props.canFulfill && canFulfillByPayment.value && props.order.status === "ACTIVE"); 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; gap: .5rem;
padding: 10px; padding: 10px;
display: flex; display: flex;
background: var(--td-bg-color-container);
flex-direction: column; 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 { .header {
gap: .5rem; gap: .5rem;
display: flex; display: flex;
+5 -2
View File
@@ -5,7 +5,7 @@
<h1 v-text="appName" /> <h1 v-text="appName" />
<p v-text="appDescription" /> <p v-text="appDescription" />
</section> </section>
<t-cell-group title="协议" theme="card" v-entrance-transition="70"> <t-cell-group theme="card" v-entrance-transition="70">
<t-cell <t-cell
v-for="item in agreementItemList" v-for="item in agreementItemList"
:key="item.code" :key="item.code"
@@ -21,6 +21,8 @@
</t-cell-group> </t-cell-group>
<footer class="footer" v-entrance-transition="140"> <footer class="footer" v-entrance-transition="140">
<p>技术支持:夜雨</p> <p>技术支持:夜雨</p>
<p>联系技术:imyeyu@qq.com</p>
<p>短信服务支持:广州易保网络科技有限公司</p>
<p>Copyright © 2026 imyeyu.com</p> <p>Copyright © 2026 imyeyu.com</p>
</footer> </footer>
</div> </div>
@@ -69,6 +71,7 @@ const agreementItemList: AgreementItem[] = [
title: "开源软件协议" title: "开源软件协议"
} }
]; ];
const agreementCodeList = agreementItemList.map(item => item.code);
const settingMap = ref<Record<string, Setting>>({}); const settingMap = ref<Record<string, Setting>>({});
@@ -92,7 +95,7 @@ async function openAgreement(item: AgreementItem): Promise<void> {
async function loadSettings(): Promise<void> { async function loadSettings(): Promise<void> {
try { try {
settingMap.value = await SettingAPI.map("GAO_SYSTEM"); settingMap.value = await SettingAPI.map("GAO_SYSTEM", undefined, agreementCodeList);
} catch (error) { } catch (error) {
Message.error(getErrorMessage(error, "协议配置加载失败")); Message.error(getErrorMessage(error, "协议配置加载失败"));
} }
+333
View File
@@ -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>
+104
View File
@@ -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>
+9 -1
View File
@@ -21,7 +21,7 @@
> >
<template #title> <template #title>
<div class="header"> <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)" /> <t-tag size="small" :theme="getStoreStatusTheme(item.status)" variant="outline" :content="getStoreStatusText(item.status)" />
</div> </div>
</template> </template>
@@ -259,9 +259,17 @@ onUnmounted(() => {
.item { .item {
:deep(.t-cell__title) {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
}
.header { .header {
gap: .5rem; gap: .5rem;
display: flex; display: flex;
overflow: hidden;
min-width: 0;
align-items: center; align-items: center;
} }
+130 -53
View File
@@ -1,22 +1,38 @@
<template> <template>
<div class="entry-page"> <div class="home-tab">
<section class="entry-list"> <section
v-for="group in visibleEntryGroupList"
:key="group.title"
class="entry-group"
>
<h2 v-text="group.title" />
<div class="items" :class="group.layout">
<div <div
v-entrance-transition="index * 70" v-entrance-transition="index * 70"
v-for="(item, index) in visibleEntryList" v-for="(item, index) in group.entryList"
:key="item.path" :key="item.path"
class="item" class="item"
:class="{ wide: item.wide }" :class="{ quick: item.path === '/customer/quick-sign-in' }"
@click="go(item.path)" @click="go(item.path)"
> >
<div class="body"> <div class="body">
<t-icon :name="item.icon" size="1.75rem" /> <t-icon :name="item.icon" size="1.75rem" />
<h2 v-text="item.title" /> <h2 v-text="item.title" />
</div> </div>
<p v-text="item.desc" /> </div>
</div> </div>
</section> </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> </div>
</template> </template>
@@ -35,32 +51,81 @@ const userStore = useUserStore();
const unreadNotifyCount = ref(0); const unreadNotifyCount = ref(0);
let isPageActive = true; 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", title: "客户管理",
permission: permissionCodes.eventRecord.create, layout: "customer",
icon: "scan", entryList: [
title: "快速扫码登记",
desc: "扫码选择客户并按配置事件登记",
wide: true
},
{ {
path: "/management/customers/create?redirect=/customers", path: "/management/customers/create?redirect=/customers",
permission: permissionCodes.customer.create, permission: permissionCodes.customer.create,
icon: "edit-1", icon: "user-add",
title: "录入", title: "录入"
desc: "新建客户档案并完善基础资料"
}, },
{ {
path: "/customer/registration", path: "/customer/registration",
permission: permissionCodes.eventRecord.create, permission: permissionCodes.eventRecord.create,
icon: "task-checked", icon: "task-checked",
title: "登记", title: "登记"
desc: "按流程完成客户登记" },
{
path: "/customer/quick-sign-in",
permission: permissionCodes.eventRecord.create,
icon: "scan",
title: "快速扫码登记"
}
]
},
{
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)); const canReadNotify = computed(() => userStore.hasPermissions(corePermissionCodes.notify.read));
async function go(path: string): Promise<void> { async function go(path: string): Promise<void> {
@@ -99,22 +164,40 @@ onUnmounted(() => {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.entry-page { .home-tab {
gap: 2rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem; padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
flex-direction: column;
.entry-list { .entry-group {
display: grid;
gap: .875rem; & + & {
grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 1.5rem;
} }
> h2 {
color: #52607a;
margin: 0 0 .75rem 0;
font-size: .875rem;
font-weight: 700;
}
.items {
gap: .875rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
.item { .item {
color: #0f766e; --entry-color: #0f766e;
--entry-background: rgba(255, 255, 255, .92);
color: var(--entry-color);
padding: 1rem; padding: 1rem;
display: flex; display: flex;
position: relative; position: relative;
text-align: center; text-align: center;
background: rgba(255, 255, 255, .92); background: var(--entry-background);
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08); box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
align-items: center; align-items: center;
aspect-ratio: 1; aspect-ratio: 1;
@@ -122,6 +205,11 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
&.quick {
color: var(--entry-background);
background: var(--entry-color);
}
.body { .body {
inset: 1rem; inset: 1rem;
display: flex; display: flex;
@@ -130,49 +218,38 @@ onUnmounted(() => {
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
}
h2 { h2 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: .75rem;
line-height: 1.5; line-height: 1.5;
} }
p {
left: 1rem;
right: 1rem;
bottom: 1rem;
color: #7b889d;
margin: 0;
position: absolute;
font-size: .75rem;
line-height: 1.25;
} }
}
&.wide {
grid-column: 1 / -1;
aspect-ratio: 2 / 1;
} }
} }
.tips { .help {
font-size: .875rem; --td-button-border-radius: 1rem;
margin-top: 1rem;
text-indent: 2em; width: 8rem;
margin: 0 auto;
background: white;
box-shadow: var(--td-shadow-3);
} }
} }
:global(html.theme-dark) { :global(html.theme-dark) {
.entry-page { .entry-page {
.item { .entry-group > h2 {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
p {
color: #94a3b8; color: #94a3b8;
} }
.item {
--entry-background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
} }
} }
} }
</style> </style>
+17
View File
@@ -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: "经营分析", title: "经营分析",
items: [ items: [
+7
View File
@@ -238,7 +238,14 @@ const menuGroupList = computed<MenuGroup[]>(() => [
] ]
}, },
{ {
title: "其他",
items: [ items: [
{
icon: "file-1",
path: "/management/help",
title: "使用说明",
visible: true
},
{ {
icon: "info-circle", icon: "info-circle",
path: "/management/about", path: "/management/about",
+9 -2
View File
@@ -49,7 +49,8 @@
/> />
</div> </div>
</t-form> </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> </section>
</div> </div>
</template> </template>
@@ -61,6 +62,7 @@ import { CommonAPI, NotifyAPI, useUserStore, type LoginResponse } from "timi-web
import ManagedUserAPI from "@/api/ManagedUserAPI"; import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useCurrentStoreStore } from "@/store/currentStoreStore";
import Logo from "@/pages/user/component/Logo.vue"; import Logo from "@/pages/user/component/Logo.vue";
import UserAgreement from "@/pages/user/component/UserAgreement.vue";
type CaptchaExpose = { captchaId: string; update: () => Promise<void> }; type CaptchaExpose = { captchaId: string; update: () => Promise<void> };
@@ -75,6 +77,7 @@ const isValid = ref(false);
const expireAt = ref(0); const expireAt = ref(0);
const invalidMessage = ref("邀请码无效或已过期,请联系邀请人重新生成。"); const invalidMessage = ref("邀请码无效或已过期,请联系邀请人重新生成。");
const isSubmitting = ref(false); const isSubmitting = ref(false);
const hasAgreed = ref(false);
const captchaRef = ref<CaptchaExpose | null>(null); const captchaRef = ref<CaptchaExpose | null>(null);
const formModel = reactive({ const formModel = reactive({
phoneNo: "", phoneNo: "",
@@ -193,7 +196,11 @@ async function sendSms(): Promise<void> {
} }
async function register(): 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) { if (isExpired.value) {
Message.warning("邀请码已失效,请联系邀请人重新生成"); Message.warning("邀请码已失效,请联系邀请人重新生成");
} }
+5 -2
View File
@@ -11,8 +11,9 @@
:options="loginTypeOptions" :options="loginTypeOptions"
v-entrance-transition="140" v-entrance-transition="140"
/> />
<password-login v-if="loginType === 'password'" class="form" /> <password-login v-if="loginType === 'password'" class="form" :agreement-accepted="hasAgreed" />
<sms-login v-else class="form" /> <sms-login v-else class="form" :agreement-accepted="hasAgreed" />
<user-agreement v-model="hasAgreed" />
</section> </section>
</div> </div>
</template> </template>
@@ -21,6 +22,7 @@
import PasswordLogin from "@/pages/user/component/PasswordLogin.vue"; import PasswordLogin from "@/pages/user/component/PasswordLogin.vue";
import SmsLogin from "@/pages/user/component/SmsLogin.vue"; import SmsLogin from "@/pages/user/component/SmsLogin.vue";
import Logo from "@/pages/user/component/Logo.vue"; import Logo from "@/pages/user/component/Logo.vue";
import UserAgreement from "@/pages/user/component/UserAgreement.vue";
defineOptions({ defineOptions({
name: "LoginIndex" name: "LoginIndex"
@@ -29,6 +31,7 @@ defineOptions({
type LoginType = "password" | "sms"; type LoginType = "password" | "sms";
const loginType = ref<LoginType>("password"); const loginType = ref<LoginType>("password");
const hasAgreed = ref(false);
const loginTypeOptions = [ const loginTypeOptions = [
{ value: "password" as LoginType, label: "密码登录" }, { value: "password" as LoginType, label: "密码登录" },
{ value: "sms" as LoginType, label: "短信登录" } { value: "sms" as LoginType, label: "短信登录" }
-1
View File
@@ -39,7 +39,6 @@
<t-button <t-button
class="red bg-white cell-button" class="red bg-white cell-button"
variant="text" variant="text"
block
v-entrance-transition="140" v-entrance-transition="140"
@click="confirmLogout" @click="confirmLogout"
>退出登录</t-button> >退出登录</t-button>
+11 -1
View File
@@ -13,7 +13,7 @@
<captcha class="captcha" ref="captchaRef" :width="120" :height="40" :request="CommonAPI.captcha" /> <captcha class="captcha" ref="captchaRef" :width="120" :height="40" :request="CommonAPI.captcha" />
</div> </div>
<div class="exec"> <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> </div>
</t-form> </t-form>
</template> </template>
@@ -39,6 +39,12 @@ defineOptions({
name: "PasswordLogin" name: "PasswordLogin"
}); });
const props = withDefaults(defineProps<{
agreementAccepted?: boolean;
}>(), {
agreementAccepted: false
});
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -56,6 +62,10 @@ function getRedirectPath(): string {
} }
function validateForm(): boolean { function validateForm(): boolean {
if (!props.agreementAccepted) {
Message.warning("请先阅读并同意用户协议");
return false;
}
if (!formModel.user || !formModel.password || !formModel.captcha || !captchaRef.value?.captchaId) { if (!formModel.user || !formModel.password || !formModel.captcha || !captchaRef.value?.captchaId) {
Message.warning("请输入账号、密码和验证码"); Message.warning("请输入账号、密码和验证码");
return false; return false;
+11 -1
View File
@@ -24,7 +24,7 @@
/> />
</div> </div>
<div class="exec"> <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> </div>
</t-form> </t-form>
</template> </template>
@@ -51,6 +51,12 @@ defineOptions({
name: "SmsLogin" name: "SmsLogin"
}); });
const props = withDefaults(defineProps<{
agreementAccepted?: boolean;
}>(), {
agreementAccepted: false
});
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -136,6 +142,10 @@ async function sendSms(): Promise<void> {
} }
function validateForm(): boolean { function validateForm(): boolean {
if (!props.agreementAccepted) {
Message.warning("请先阅读并同意用户协议");
return false;
}
if (!/^1\d{10}$/.test(formModel.mobilePhone)) { if (!/^1\d{10}$/.test(formModel.mobilePhone)) {
Message.warning("请输入正确的 11 位手机号"); Message.warning("请输入正确的 11 位手机号");
return false; return false;
+311
View File
@@ -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>
+29
View File
@@ -130,6 +130,35 @@ export const storeRoutes: RouteRecordRaw[] = [
]; ];
export const managementSettingRoutes: 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", path: "/management/about",
name: "About", name: "About",
+24
View File
@@ -1,6 +1,30 @@
import type { RouteRecordRaw } from "vue-router"; import type { RouteRecordRaw } from "vue-router";
const pointRoutes: RouteRecordRaw[] = [ 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", path: "/management/points/account",
name: "PointAccountList", name: "PointAccountList",
+5
View File
@@ -29,6 +29,10 @@ export const useNavBarStore = defineStore("nav-bar", () => {
height.value = value; height.value = value;
} }
function setTitle(value?: string): void {
title.value = value || "";
}
function setBackTo(value?: string): void { function setBackTo(value?: string): void {
backTo.value = value; backTo.value = value;
} }
@@ -106,6 +110,7 @@ export const useNavBarStore = defineStore("nav-bar", () => {
isShowing, isShowing,
canBack, canBack,
setHeight, setHeight,
setTitle,
setBackTo, setBackTo,
setRightIcon, setRightIcon,
setRightBadge, setRightBadge,
+2 -1
View File
@@ -2,7 +2,7 @@ import type { Attachment, Model, PageResult, User } from "timi-web-api";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { OrderFulfillment } from "@/types/Fulfillment"; import type { OrderFulfillment } from "@/types/Fulfillment";
import type { ProductSku } from "@/types/Product"; 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"; export type PackageConfigStatus = "DRAFT" | "ACTIVE" | "INACTIVE";
@@ -79,6 +79,7 @@ export type PackagePurchaseRequest = {
purchasedAt?: number; purchasedAt?: number;
itemList?: PackagePurchaseItem[]; itemList?: PackagePurchaseItem[];
attachmentList?: PackageAttachment[]; attachmentList?: PackageAttachment[];
straightThrough?: SalesOrderStraightThroughRequest;
remark?: string | null; remark?: string | null;
}; };
+91
View File
@@ -108,3 +108,94 @@ export type PointAdjustRequest = {
pointDelta: number; pointDelta: number;
remark: string; 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;
};
+14
View File
@@ -98,6 +98,19 @@ export type DirectProductOrderItemRequest = {
remark?: string | null; 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 = { export type DirectProductOrderCreateRequest = {
requestNo: string; requestNo: string;
storeId?: string; storeId?: string;
@@ -105,6 +118,7 @@ export type DirectProductOrderCreateRequest = {
purchasedAt?: number; purchasedAt?: number;
itemList: DirectProductOrderItemRequest[]; itemList: DirectProductOrderItemRequest[];
attachmentList?: Partial<Attachment>[]; attachmentList?: Partial<Attachment>[];
straightThrough?: SalesOrderStraightThroughRequest;
remark?: string | null; remark?: string | null;
}; };
+12
View File
@@ -39,6 +39,14 @@ export const permissionCodes = {
adjust: "GAO:POINT:ADJUST", adjust: "GAO:POINT:ADJUST",
sourceValue: "GAO:POINT:SOURCE_VALUE" 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: { pointRule: {
create: "GAO:POINT_RULE:CREATE", create: "GAO:POINT_RULE:CREATE",
read: "GAO:POINT_RULE:READ", read: "GAO:POINT_RULE:READ",
@@ -188,6 +196,10 @@ export const gaoStoreScopePermissionCodes = [
permissionCodes.point.ledgerRead, permissionCodes.point.ledgerRead,
permissionCodes.point.adjust, permissionCodes.point.adjust,
permissionCodes.point.sourceValue, permissionCodes.point.sourceValue,
permissionCodes.pointRedeemSku.manage,
permissionCodes.pointRedemption.create,
permissionCodes.pointRedemption.pointAdjust,
permissionCodes.pointRedemption.return,
permissionCodes.pointRule.create, permissionCodes.pointRule.create,
permissionCodes.pointRule.read, permissionCodes.pointRule.read,
permissionCodes.pointRule.update, permissionCodes.pointRule.update,
+10
View File
@@ -0,0 +1,10 @@
.gradle/
local.properties
**/build/
*.apk
*.aab
*.idsig
*.jks
*.keystore
!gradle/wrapper/gradle-wrapper.jar
+41
View File
@@ -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 获取最新页面。
+213
View File
@@ -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'
}
+192
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

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"
}
]
}
+18
View File
@@ -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>
+42
View File
@@ -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>
+18
View File
@@ -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>
+16
View File
@@ -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' />
+42
View File
@@ -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
}
+14
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored
+249
View File
@@ -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" "$@"
+92
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
dff2536399b4ac0116642fb1c752408b59929697
+1
View File
@@ -0,0 +1 @@
include ':app'
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+47
View File
@@ -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"
}