اپ: نام برند در همهی صفحهها و پیامها یکسان شده و از یک تنظیم مرکزی خوانده میشود. اپ: کارت حسابداری در صفحهی مشتری بازطراحی شد؛ ماندهها و وضعیت لینک واضحتر نمایش داده میشوند. پنل: کارت حسابداری در پروندهی مشتری بازطراحی شد؛ ماندهها، آخرین ارسال و فرم لینک دستی واضحتر شدهاند. پنل: ستون «کد» در جدولها بهطور پیشفرض پنهان است و از انتخاب ستونها قابل نمایش است. پنل: تغییر نمایش ستونها در جدولها بلافاصله اعمال میشود. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
385 lines
11 KiB
Vue
385 lines
11 KiB
Vue
<!--
|
||
The app's confirm/notice action sheet (logout, place order, sign in, update,
|
||
done).
|
||
|
||
Replaces DialogVant / DialogVantOneButton (centered Vant dialogs whose
|
||
content was poured into img/title/button slots).
|
||
|
||
RESTYLED into user-app-v2's own design language. It used to be a floating
|
||
island inset from the screen edges, with a tinted dot-grid header, dashed
|
||
rings, twinkling sparks, gradient buttons with a sheen and a per-sheet
|
||
colour (navy / gold / green) — none of which appears anywhere else in this
|
||
app. It now uses the same parts as the rest of v2:
|
||
- a white sheet attached to the bottom edge, with the 32px top radius and
|
||
grip of the product detail sheet and the search filter sheet;
|
||
- the illustration centred on a borderless @hairlineSoft panel — the
|
||
ProductPanel / catalogue-card treatment of a piece of jewellery;
|
||
- a centred title and description under it, as on those cards;
|
||
- the detail page's action bar: an outlined pill beside a filled pill.
|
||
The filled pill is @wine (brand), or @ink for the destructive tone (logout; the class is still .iSheet--wine).
|
||
The component name, props, events and illustrations are unchanged, so no
|
||
call site had to change.
|
||
|
||
Usage:
|
||
|
||
<IslandSheet
|
||
v-model:show="open"
|
||
art="logout"
|
||
title="..."
|
||
description="..."
|
||
confirm-text="بله"
|
||
cancel-text="بی خیال"
|
||
@confirm="doIt"
|
||
/>
|
||
|
||
- `art` picks an illustration from SheetArt, and with it the sheet's tone
|
||
(the filled button's colour); `tone` overrides it. An `#art` slot can
|
||
replace the illustration entirely.
|
||
- No `cancel-text` → a single, full-width button.
|
||
- `closeOnClickOverlay` (default true) also governs swipe-down-to-dismiss;
|
||
with it off the sheet can only be left through its own buttons, and a
|
||
swipe just rubber-bands. No close (×) button: cancel, the backdrop and
|
||
the swipe already cover it.
|
||
- Performance: the illustration's own animations are held paused until the
|
||
slide-in has finished (`is-ready`, set on Vant's `opened`), so the slide has
|
||
the frame budget to itself; nothing uses backdrop-filter (blurring the page
|
||
behind a fading overlay recomputes every frame and was the main source of
|
||
jank on phones).
|
||
- Cancel closes the sheet itself and emits `cancel`; confirm only emits
|
||
`confirm` — the caller decides whether it closes (e.g. a redirect).
|
||
-->
|
||
<template>
|
||
<Popup
|
||
:show="show"
|
||
position="bottom"
|
||
teleport="body"
|
||
transition="iSheet-slide"
|
||
transition-appear
|
||
class="iSheet"
|
||
overlay-class="iSheet__overlay"
|
||
:close-on-click-overlay="closeOnClickOverlay"
|
||
close-on-popstate
|
||
@update:show="emit('update:show', $event)"
|
||
@opened="ready = true"
|
||
@closed="onClosed"
|
||
>
|
||
<div
|
||
class="iSheet__card"
|
||
:class="[`iSheet--${resolvedTone}`, { 'is-dragging': dragging, 'is-ready': ready, 'is-single': !cancelText }]"
|
||
:style="cardStyle"
|
||
dir="rtl"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
@touchstart.passive="onTouchStart"
|
||
@touchmove.passive="onTouchMove"
|
||
@touchend="onTouchEnd"
|
||
@touchcancel="onTouchEnd"
|
||
>
|
||
<span class="iSheet__grip" aria-hidden="true"></span>
|
||
|
||
<div class="iSheet__panel">
|
||
<div class="iSheet__art">
|
||
<slot name="art">
|
||
<SheetArt v-if="art" :name="art" />
|
||
</slot>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 class="iSheet__title">{{ title }}</h3>
|
||
<p v-if="description" class="iSheet__desc">{{ description }}</p>
|
||
|
||
<!-- The detail page's bar order: the secondary pill first in markup, so
|
||
RTL lays it on the right, and the filled pill fills the rest. -->
|
||
<div class="iSheet__actions">
|
||
<button v-if="cancelText" type="button" class="iSheet__btn iSheet__btn--ghost" @click="cancel">
|
||
{{ cancelText }}
|
||
</button>
|
||
<button type="button" class="iSheet__btn iSheet__btn--primary" @click="emit('confirm')">
|
||
{{ confirmText }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { Popup } from 'vant'
|
||
import SheetArt, { type SheetArtName } from './SheetArt.vue'
|
||
|
||
// Kept as the original five for API compatibility; v2 renders them as two
|
||
// looks — destructive (wine) and everything else (ink). See the styles.
|
||
type Tone = 'ink' | 'wine' | 'navy' | 'gold' | 'green'
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
show: boolean
|
||
title: string
|
||
description?: string
|
||
confirmText: string
|
||
cancelText?: string
|
||
art?: SheetArtName
|
||
tone?: Tone
|
||
closeOnClickOverlay?: boolean
|
||
}>(),
|
||
{ closeOnClickOverlay: true },
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:show', value: boolean): void
|
||
(e: 'confirm'): void
|
||
(e: 'cancel'): void
|
||
}>()
|
||
|
||
const artTone: Record<SheetArtName, Tone> = {
|
||
logout: 'wine',
|
||
login: 'navy',
|
||
order: 'gold',
|
||
update: 'navy',
|
||
success: 'green',
|
||
}
|
||
const resolvedTone = computed<Tone>(() => props.tone ?? (props.art ? artTone[props.art] : 'ink'))
|
||
|
||
const ready = ref(false)
|
||
|
||
function close() {
|
||
emit('update:show', false)
|
||
}
|
||
|
||
function onClosed() {
|
||
ready.value = false
|
||
dragY.value = 0
|
||
}
|
||
|
||
function cancel() {
|
||
emit('cancel')
|
||
close()
|
||
}
|
||
|
||
// Swipe down to dismiss. Only the card moves while dragging; on release it
|
||
// either springs back or the popup's own leave transition carries it off
|
||
// from wherever the finger left it (reset once fully closed).
|
||
const DISMISS_DISTANCE = 90
|
||
const DISMISS_VELOCITY = 0.5 // px per ms
|
||
|
||
const dragY = ref(0)
|
||
const dragging = ref(false)
|
||
let startY = 0
|
||
let startTime = 0
|
||
|
||
const cardStyle = computed(() =>
|
||
dragY.value ? { transform: `translate3d(0, ${dragY.value}px, 0)` } : undefined,
|
||
)
|
||
|
||
function onTouchStart(e: TouchEvent) {
|
||
if ((e.target as HTMLElement).closest('button')) return
|
||
startY = e.touches[0].clientY
|
||
startTime = Date.now()
|
||
dragging.value = true
|
||
}
|
||
|
||
function onTouchMove(e: TouchEvent) {
|
||
if (!dragging.value) return
|
||
const dy = e.touches[0].clientY - startY
|
||
// Downward follows the finger when dismissable, otherwise rubber-bands;
|
||
// upward always rubber-bands.
|
||
dragY.value = dy > 0 ? (props.closeOnClickOverlay ? dy : dy * 0.2) : dy * 0.12
|
||
}
|
||
|
||
function onTouchEnd() {
|
||
if (!dragging.value) return
|
||
dragging.value = false
|
||
const velocity = dragY.value / Math.max(1, Date.now() - startTime)
|
||
if (props.closeOnClickOverlay && (dragY.value > DISMISS_DISTANCE || (dragY.value > 30 && velocity > DISMISS_VELOCITY))) {
|
||
close()
|
||
} else {
|
||
dragY.value = 0
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="less">
|
||
// Global, not scoped: the popup is teleported to <body>.
|
||
|
||
// See-through dim, no blur — the same backdrop as the filter sheet.
|
||
.iSheet__overlay {
|
||
background: rgba(20, 22, 26, 0.42);
|
||
}
|
||
|
||
.iSheet.van-popup {
|
||
background: transparent;
|
||
overflow: visible;
|
||
}
|
||
|
||
// The iOS sheet curve: fast out of the gate, long soft landing. Transform
|
||
// only, on its own compositor layer, so it never waits on paint.
|
||
.iSheet-slide-enter-active,
|
||
.iSheet-slide-leave-active {
|
||
will-change: transform;
|
||
}
|
||
.iSheet-slide-enter-active {
|
||
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
|
||
}
|
||
.iSheet-slide-leave-active {
|
||
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
|
||
}
|
||
.iSheet-slide-enter-from,
|
||
.iSheet-slide-leave-to {
|
||
transform: translate3d(0, 100%, 0);
|
||
}
|
||
|
||
// Hold the illustration's animations until the sheet has landed.
|
||
.iSheet__card:not(.is-ready) *,
|
||
.iSheet__card:not(.is-ready) *::after {
|
||
animation-play-state: paused !important;
|
||
}
|
||
|
||
// Two looks, one per job: the filled button is @ink for the destructive
|
||
// action (logout) and @wine — the brand colour — for everything else.
|
||
.iSheet__card {
|
||
--t: @wine;
|
||
--t-on: #fff;
|
||
--t-rgb: 124, 0, 53;
|
||
}
|
||
.iSheet--wine {
|
||
--t: @ink;
|
||
--t-on: @onInk;
|
||
--t-rgb: 20, 22, 26;
|
||
}
|
||
|
||
// The detail page's / filter sheet's shell: white, attached to the bottom
|
||
// edge, 32px top radius, a centred column on wide screens.
|
||
.iSheet__card {
|
||
position: relative;
|
||
max-width: 768px;
|
||
margin: 0 auto;
|
||
padding: 8px 16px calc(14px + env(safe-area-inset-bottom, 0px));
|
||
border-radius: 32px 32px 0 0;
|
||
background: @canvas;
|
||
text-align: center;
|
||
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
|
||
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
|
||
touch-action: none;
|
||
|
||
&.is-dragging {
|
||
transition: none;
|
||
}
|
||
}
|
||
|
||
.iSheet__grip {
|
||
display: block;
|
||
width: 38px;
|
||
height: 4px;
|
||
margin: 0 auto 12px;
|
||
border-radius: 999px;
|
||
background: @hairline;
|
||
}
|
||
|
||
// The ProductPanel treatment: the subject centred on a borderless soft-grey
|
||
// panel at the catalogue cards' 32px radius (one step in, 26px, since it sits
|
||
// inside the sheet's own 32px corner).
|
||
.iSheet__panel {
|
||
height: 150px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 26px;
|
||
background: @hairlineSoft;
|
||
}
|
||
|
||
.iSheet__art {
|
||
width: 112px;
|
||
height: 112px;
|
||
|
||
> img,
|
||
> svg,
|
||
> canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
}
|
||
|
||
// Every element sets its own font-size: style.less's `*` rule pins each one
|
||
// to 14px, so nothing inherits. Sizes are the detail page's: 16px for the
|
||
// heading (its section titles), 12.5px / line-height 2 for body copy.
|
||
.iSheet__title {
|
||
margin: 18px 8px 0;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
line-height: 1.75;
|
||
color: @ink;
|
||
}
|
||
|
||
.iSheet__desc {
|
||
margin: 6px 12px 0;
|
||
font-size: 12.5px;
|
||
font-weight: 400;
|
||
line-height: 2;
|
||
color: @inkMuted;
|
||
}
|
||
|
||
.iSheet__actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
// Scoped under the card on purpose: Tailwind's preflight resets
|
||
// `[type='button']` to a transparent, image-less background with the same
|
||
// specificity as a single class, and App.vue (which uses this sheet) is
|
||
// imported before main.ts loads tailwind.css — so a bare `.iSheet__btn--primary`
|
||
// loses on source order and the button renders white-on-white.
|
||
.iSheet__card .iSheet__btn {
|
||
height: 48px;
|
||
border-radius: 999px;
|
||
font-family: inherit;
|
||
font-size: 13.5px;
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
-webkit-tap-highlight-color: transparent;
|
||
transition: transform 0.18s ease, background-color 0.18s ease;
|
||
|
||
&:active {
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
// The detail page's outlined pill.
|
||
&--ghost {
|
||
flex: 0 0 auto;
|
||
min-width: 108px;
|
||
padding: 0 22px;
|
||
color: @ink;
|
||
background: @canvas;
|
||
border: 1px solid @hairline;
|
||
|
||
&:active {
|
||
background: @hairlineSoft;
|
||
}
|
||
}
|
||
|
||
// The detail page's primary pill.
|
||
&--primary {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 0 18px;
|
||
border: 0;
|
||
color: var(--t-on);
|
||
background: var(--t);
|
||
box-shadow: 0 14px 30px -18px rgba(var(--t-rgb), 0.9);
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.iSheet-slide-enter-active,
|
||
.iSheet-slide-leave-active {
|
||
transition-duration: 0.01s;
|
||
}
|
||
.iSheet__card *,
|
||
.iSheet__card *::after {
|
||
animation: none !important;
|
||
}
|
||
}
|
||
</style>
|