Files
tehran_taj/apps/user-app-v2/src/components/ui/IslandSheet.vue
T
s.esmaeiliandClaude Haiku 5.5 532ceee189 feat: move brand name to one config and redesign the accounting card
اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-14 13:31:20 +03:30

385 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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>