Merge design_ui_v2 into design_ui so the new design system can be previewed without dropping later product work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 16:46:00 +03:30
co-authored by Cursor
87 changed files with 6722 additions and 5040 deletions
+3 -3
View File
@@ -90,7 +90,7 @@ form {
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: var(--radius);
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
}
@@ -110,7 +110,7 @@ input {
button {
border: 0;
border-radius: 10px;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
font: inherit;
@@ -126,7 +126,7 @@ button:disabled {
.answer {
background: #f0fdfa;
border-radius: 12px;
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
}
+47 -58
View File
@@ -2,6 +2,7 @@
import { onMounted, ref, watch } from 'vue';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiField, UiInput, UiSelect } from '@/components/ui';
import type {
CustomFieldDefinitionDto,
CustomFieldEntity,
@@ -58,27 +59,28 @@ onMounted(load);
<template>
<div v-if="loading" class="muted">بارگذاری فیلدهای سفارشی…</div>
<div v-else-if="fields.length" class="custom-block">
<h3>فیلدهای سفارشی</h3>
<div class="grid">
<div v-for="field in fields" :key="field.id" class="field">
<label :for="`cf-${field.key}`">
{{ field.label }}
<span v-if="field.required" class="req">*</span>
</label>
<select
<h3 class="custom-title">فیلدهای سفارشی</h3>
<div class="crm-form-grid-2">
<UiField
v-for="field in fields"
:key="field.id"
:label="field.label"
:required="field.required"
:for-id="`cf-${field.key}`"
>
<UiSelect
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
:value="String(displayValue(field.key))"
@change="setValue(field.key, ($event.target as HTMLSelectElement).value)"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event)"
>
<option value="">انتخاب کنید</option>
<option v-for="opt in field.options" :key="opt" :value="opt">
{{ opt }}
</option>
</select>
</UiSelect>
<label v-else-if="field.fieldType === 'boolean'" class="check">
<label v-else-if="field.fieldType === 'boolean'" class="cf-check">
<input
:id="`cf-${field.key}`"
type="checkbox"
@@ -87,20 +89,18 @@ onMounted(load);
setValue(field.key, ($event.target as HTMLInputElement).checked)
"
/>
فعال
<span>فعال</span>
</label>
<input
<UiInput
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
type="number"
:value="String(displayValue(field.key))"
@input="
:model-value="props.modelValue[field.key] != null ? Number(props.modelValue[field.key]) : ''"
@update:model-value="
setValue(
field.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
$event === '' || $event == null ? null : Number($event),
)
"
/>
@@ -112,66 +112,55 @@ onMounted(load);
@update:model-value="setValue(field.key, $event || null)"
/>
<input
<UiInput
v-else
:id="`cf-${field.key}`"
type="text"
:value="String(displayValue(field.key))"
@input="setValue(field.key, ($event.target as HTMLInputElement).value)"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event)"
/>
</div>
</UiField>
</div>
</div>
</template>
<style scoped>
.custom-block {
display: grid;
gap: 0.75rem;
padding-top: 0.4rem;
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-top: var(--sp-4);
border-top: 1px dashed var(--line);
width: 100%;
}
h3 {
.custom-title {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.muted {
color: var(--muted);
font-size: var(--fs-xs);
}
.req {
color: #b42318;
}
.check {
display: flex;
.cf-check {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
gap: var(--sp-2);
min-height: var(--control-h-md);
font-size: var(--fs-base);
font-weight: var(--fw-medium);
color: var(--ink);
cursor: pointer;
}
select,
input[type='text'],
input[type='number'] {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
background: #fff;
font: inherit;
width: 100%;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
.cf-check input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
</style>
+2 -2
View File
@@ -289,7 +289,7 @@ onMounted(load);
.composer input {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
background: #fff;
}
@@ -316,7 +316,7 @@ onMounted(load);
gap: 0.75rem;
align-items: flex-start;
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fbfcfb;
}
+14 -14
View File
@@ -675,7 +675,7 @@ onBeforeUnmount(() => {
justify-content: flex-start;
gap: 0.8rem;
min-height: 52px;
border-radius: 16px;
border-radius: var(--radius);
padding: 0.45rem 0.85rem 0.45rem 0.45rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fffd 100%);
}
@@ -698,7 +698,7 @@ onBeforeUnmount(() => {
left: 50%;
bottom: 50%;
transform-origin: center bottom;
border-radius: 99px;
border-radius: var(--radius);
background: #0f766e;
}
@@ -726,7 +726,7 @@ onBeforeUnmount(() => {
}
.clock-text {
font-weight: 800;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
font-size: 1.05rem;
@@ -768,7 +768,7 @@ onBeforeUnmount(() => {
max-height: calc(100vh - 16px);
overflow: auto;
background: #fff;
border-radius: 16px;
border-radius: var(--radius);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
padding: 0.75rem 0.8rem 0.7rem;
font-family: IRANSans, Tahoma, sans-serif;
@@ -797,7 +797,7 @@ onBeforeUnmount(() => {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
border-radius: var(--radius);
background: #f1f5f9;
color: #0f766e;
display: grid;
@@ -826,7 +826,7 @@ onBeforeUnmount(() => {
.day {
height: 34px;
border: 0;
border-radius: 9px;
border-radius: var(--radius);
background: none;
color: #0f172a;
font: inherit;
@@ -881,12 +881,12 @@ onBeforeUnmount(() => {
min-width: 5.1rem;
height: 4.1rem;
border: 0;
border-radius: 14px;
border-radius: var(--radius);
background: #f1f5f9;
color: #0f172a;
font: inherit;
font-size: 2.35rem;
font-weight: 800;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
cursor: pointer;
@@ -900,7 +900,7 @@ onBeforeUnmount(() => {
.md-colon {
font-size: 2.2rem;
font-weight: 800;
font-weight: 600;
color: #0f172a;
line-height: 1;
padding-bottom: 0.15rem;
@@ -1005,7 +1005,7 @@ onBeforeUnmount(() => {
margin: 0;
text-align: center;
font-size: 1.2rem;
font-weight: 800;
font-weight: 600;
color: #0f766e;
letter-spacing: 0.04em;
}
@@ -1033,7 +1033,7 @@ onBeforeUnmount(() => {
height: 220px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: var(--radius);
background: #f8fafc;
padding: 0.25rem;
scrollbar-width: thin;
@@ -1044,7 +1044,7 @@ onBeforeUnmount(() => {
width: 100%;
height: 36px;
border: 0;
border-radius: 8px;
border-radius: var(--radius);
background: transparent;
color: #334155;
font: inherit;
@@ -1087,9 +1087,9 @@ onBeforeUnmount(() => {
margin-inline-start: auto;
background: #14b8a6;
color: #fff;
border-radius: 10px;
border-radius: var(--radius);
padding: 0.4rem 0.8rem;
font-weight: 800;
font-weight: 600;
font-size: 0.8rem;
}
+2 -2
View File
@@ -203,7 +203,7 @@ h2 {
textarea {
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
background: #fff;
font: inherit;
@@ -221,7 +221,7 @@ textarea {
.items li {
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fff;
}
@@ -231,7 +231,7 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px dashed var(--accent);
border-radius: 12px;
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
@@ -281,7 +281,7 @@ header {
input[type='text'],
textarea {
border: 1px solid var(--line);
border-radius: 10px;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
+59 -48
View File
@@ -5,8 +5,9 @@ import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
const FAB_SIZE = 36;
const FAB_MARGIN = 20;
const POS_KEY = 'mento.quickCreateFab';
const FAB_MARGIN_BOTTOM = 24;
const FAB_MARGIN_LEFT = 24;
const POS_KEY = 'mento.quickCreateFab_v2';
type FabPos = { left: number; top: number };
@@ -67,7 +68,10 @@ const router = useRouter();
const hidden = computed(() => route.name === 'person-detail');
const isQuickCreateOpen = ref(false);
const ready = ref(false);
const fabPos = ref<FabPos>({ left: FAB_MARGIN, top: FAB_MARGIN });
const fabPos = ref<FabPos>({
left: FAB_MARGIN_LEFT,
top: typeof window !== 'undefined' ? window.innerHeight - FAB_MARGIN_BOTTOM - FAB_SIZE : 600,
});
const viewport = ref({ w: 1280, h: 800 });
const dragging = ref(false);
let dragMoved = false;
@@ -75,10 +79,10 @@ let dragOffsetX = 0;
let dragOffsetY = 0;
function defaultPos(): FabPos {
const bottomGap = viewport.value.w <= 900 ? FAB_MARGIN + 64 : FAB_MARGIN;
const extra = viewport.value.w <= 900 ? 64 : 0;
return {
left: FAB_MARGIN,
top: Math.max(FAB_MARGIN, viewport.value.h - bottomGap - FAB_SIZE),
left: FAB_MARGIN_LEFT,
top: Math.max(8, viewport.value.h - FAB_MARGIN_BOTTOM - extra - FAB_SIZE),
};
}
@@ -171,7 +175,7 @@ const fabStyle = computed(() => ({
const panelStyle = computed(() => {
const gap = 12;
const edge = 16;
const width = Math.min(390, viewport.value.w - 32);
const width = Math.min(300, viewport.value.w - 32);
let left = fabPos.value.left;
left = Math.min(left, viewport.value.w - width - edge);
left = Math.max(edge, left);
@@ -278,7 +282,7 @@ onBeforeUnmount(() => {
@click="go(item.to)"
>
<span class="item-ico" :class="item.tone">
<UiIcon :name="item.icon" :size="26" />
<UiIcon :name="item.icon" :size="18" />
</span>
<span class="copy">
<strong>{{ item.title }}</strong>
@@ -303,7 +307,7 @@ onBeforeUnmount(() => {
@pointercancel="onFabPointerUp"
>
<span class="fab-face" aria-hidden="true">
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="28" />
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
</span>
</button>
</div>
@@ -328,25 +332,26 @@ onBeforeUnmount(() => {
.panel {
position: fixed;
z-index: 92;
width: 390px;
max-height: min(540px, calc(100dvh - 80px));
width: 300px;
max-height: min(480px, calc(100dvh - 80px));
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 28px;
padding: 16px 16px 12px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px 12px 10px;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.panel h2 {
flex-shrink: 0;
margin: 0 0 12px;
margin: 0 0 10px;
text-align: right;
font-size: 20px;
font-weight: 800;
color: #111827;
font-size: var(--fs-md);
font-weight: 700;
color: var(--ink);
}
.items {
@@ -354,31 +359,37 @@ onBeforeUnmount(() => {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
padding-left: 2px;
overscroll-behavior: contain;
}
.item {
width: 100%;
min-height: 68px;
min-height: 46px;
display: flex;
align-items: center;
gap: 14px;
padding: 10px 14px;
border: 1px solid #eef1f2;
border-radius: 16px;
gap: 10px;
padding: 6px 10px;
border: 1px solid var(--line-light);
border-radius: var(--radius);
background: #fff;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
box-shadow: var(--shadow-xs);
font: inherit;
text-align: right;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.item:hover {
background: var(--surface-hover);
border-color: var(--line-strong);
}
.item-ico {
width: 52px;
height: 52px;
border-radius: 14px;
width: 34px;
height: 34px;
border-radius: var(--radius);
display: grid;
place-items: center;
color: #fff;
@@ -390,7 +401,7 @@ onBeforeUnmount(() => {
}
.item-ico.company {
background: #22d3ee;
background: #0891b2;
}
.item-ico.deal {
@@ -398,7 +409,7 @@ onBeforeUnmount(() => {
}
.item-ico.activity {
background: #f97316;
background: #ea580c;
}
.item-ico.note {
@@ -407,33 +418,34 @@ onBeforeUnmount(() => {
.copy {
display: grid;
gap: 4px;
gap: 2px;
min-width: 0;
text-align: right;
}
.copy strong {
font-size: 16px;
font-size: var(--fs-sm);
font-weight: 600;
color: #111827;
color: var(--ink);
line-height: 1.2;
}
.copy small {
font-size: 13px;
color: #94a3b8;
line-height: 1.5;
font-size: var(--fs-xs);
color: var(--muted);
line-height: 1.3;
}
.fab {
position: fixed;
z-index: 93;
box-sizing: border-box;
width: 36px;
height: 36px;
width: var(--control-h);
height: var(--control-h);
padding: 0;
margin: 0;
border: 0;
border-radius: 10px;
border-radius: var(--radius);
overflow: hidden;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
color: #fff;
@@ -442,7 +454,7 @@ onBeforeUnmount(() => {
line-height: 0;
direction: ltr;
box-shadow:
0 8px 22px rgba(13, 148, 136, 0.4),
0 4px 14px rgba(13, 148, 136, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
cursor: grab;
touch-action: none;
@@ -463,29 +475,28 @@ onBeforeUnmount(() => {
.fab :deep(.ico) {
display: block;
width: 28px;
height: 28px;
max-width: 28px;
max-height: 28px;
width: 20px;
height: 20px;
max-width: 20px;
max-height: 20px;
stroke-width: 1.7;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
}
.fab:hover {
filter: brightness(1.06);
box-shadow:
0 10px 26px rgba(13, 148, 136, 0.48),
0 6px 18px rgba(13, 148, 136, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.fab.open {
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
}
.fab.dragging {
cursor: grabbing;
transform: scale(1.05);
box-shadow: 0 12px 28px rgba(13, 148, 136, 0.5);
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
}
</style>
+23 -2
View File
@@ -64,7 +64,13 @@ defineProps<{
| 'logout'
| 'pulse'
| 'sliders'
| 'camera';
| 'camera'
| 'arrow-right'
| 'arrow-left'
| 'chevron-right'
| 'chevron-left'
| 'chevrons-right'
| 'chevrons-left';
size?: number;
}>();
</script>
@@ -218,9 +224,24 @@ defineProps<{
<template v-else-if="name === 'plus'">
<path d="M12 6v12M6 12h12" />
</template>
<template v-else-if="name === 'chevron'">
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
<path d="m9 6 6 6-6 6" />
</template>
<template v-else-if="name === 'chevron-left'">
<path d="m15 18-6-6 6-6" />
</template>
<template v-else-if="name === 'chevrons-right'">
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
</template>
<template v-else-if="name === 'chevrons-left'">
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
</template>
<template v-else-if="name === 'arrow-right'">
<path d="M5 12h14M12 5l7 7-7 7" />
</template>
<template v-else-if="name === 'arrow-left'">
<path d="M19 12H5M12 19l-7-7 7-7" />
</template>
<template v-else-if="name === 'star'">
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
</template>
+1 -1
View File
@@ -52,7 +52,7 @@ img {
em {
font-style: normal;
color: #fff;
font-weight: 800;
font-weight: 600;
font-size: 0.62rem;
line-height: 1;
}
@@ -373,7 +373,7 @@ onBeforeUnmount(() => {
.meter i {
display: block;
width: 3px;
border-radius: 99px;
border-radius: var(--radius);
background: #5eead4;
box-shadow: 0 0 8px rgba(94, 234, 212, 0.45);
transition: height 70ms linear;
@@ -402,7 +402,7 @@ onBeforeUnmount(() => {
height: 38px;
min-height: 38px;
padding: 0;
border-radius: 12px;
border-radius: var(--radius);
border-color: transparent;
background: #f8fafc;
justify-content: center;
@@ -296,8 +296,8 @@ onBeforeUnmount(() => {
.head h2 {
margin: 0;
text-align: center;
font-size: 1.05rem;
font-weight: 800;
font-size: 16px;
font-weight: 500;
color: #0f172a;
}
@@ -332,7 +332,7 @@ onBeforeUnmount(() => {
align-items: flex-start;
padding: 0.9rem 0.95rem;
border: 1px solid #e2e8f0;
border-radius: 18px;
border-radius: var(--radius);
background: #fff;
}
@@ -370,7 +370,7 @@ onBeforeUnmount(() => {
.ctx-copy h3 {
margin: 0.1rem 0 0;
font-size: 0.98rem;
font-weight: 800;
font-weight: 600;
line-height: 1.55;
}
@@ -404,7 +404,7 @@ onBeforeUnmount(() => {
.editor {
border: 1px solid #e2e8f0;
border-radius: 16px;
border-radius: var(--radius);
background: #fff;
min-height: 11.5rem;
display: flex;
@@ -448,13 +448,13 @@ onBeforeUnmount(() => {
width: 36px;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 10px;
border-radius: var(--radius);
background: #fff;
color: #0f766e;
display: grid;
place-items: center;
font: inherit;
font-weight: 800;
font-weight: 600;
cursor: pointer;
}
@@ -473,7 +473,7 @@ onBeforeUnmount(() => {
font-size: 0.74rem;
font-weight: 700;
padding: 0.42rem 0.65rem;
border-radius: 10px;
border-radius: var(--radius);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
}
@@ -497,7 +497,7 @@ onBeforeUnmount(() => {
max-height: 12rem;
overflow: auto;
background: #fff;
border-radius: 12px;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
z-index: 2;
}
@@ -510,7 +510,7 @@ onBeforeUnmount(() => {
text-align: start;
font: inherit;
padding: 0.55rem 0.65rem;
border-radius: 8px;
border-radius: var(--radius);
color: #0f172a;
cursor: pointer;
}
@@ -536,7 +536,7 @@ onBeforeUnmount(() => {
align-items: flex-start;
gap: 0.5rem;
padding: 0.72rem 0.8rem;
border-radius: 12px;
border-radius: var(--radius);
background: #ecfdf5;
color: #0f766e;
font-size: 0.8rem;
@@ -559,7 +559,7 @@ onBeforeUnmount(() => {
height: var(--control-h);
border-radius: var(--radius);
font: inherit;
font-weight: 800;
font-weight: 600;
cursor: pointer;
}
@@ -600,7 +600,7 @@ onBeforeUnmount(() => {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: 22px;
border-radius: var(--radius, 10px);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
@@ -274,8 +274,8 @@ onBeforeUnmount(() => {
}
.titles h2 {
font-size: 1.02rem;
font-weight: 800;
font-size: 16px;
font-weight: 500;
color: #0f172a;
}
@@ -319,7 +319,7 @@ onBeforeUnmount(() => {
min-height: 72px;
padding: 0.7rem 0.9rem;
border: 1px solid #e8eef3;
border-radius: 16px;
border-radius: var(--radius);
background: #fff;
font: inherit;
text-align: start;
@@ -340,7 +340,7 @@ onBeforeUnmount(() => {
.label,
.copy strong {
font-size: 0.95rem;
font-weight: 800;
font-weight: 600;
color: #0f172a;
}
@@ -365,7 +365,7 @@ onBeforeUnmount(() => {
.ico-box {
width: 40px;
height: 40px;
border-radius: 10px;
border-radius: var(--radius);
background: #e6f7f4;
color: #0d9488;
display: grid;
@@ -375,13 +375,13 @@ onBeforeUnmount(() => {
.when {
color: #0d9488;
font-size: 0.74rem;
font-weight: 800;
font-weight: 600;
white-space: nowrap;
}
.custom-box {
border: 1px solid #e8eef3;
border-radius: 16px;
border-radius: var(--radius);
padding: 0.85rem 0.9rem;
display: grid;
gap: 0.55rem;
@@ -419,7 +419,7 @@ onBeforeUnmount(() => {
align-items: flex-start;
gap: 0.55rem;
padding: 0.85rem 0.9rem;
border-radius: 14px;
border-radius: var(--radius);
background: #f8fafc;
color: #475569;
font-size: 0.8rem;
@@ -452,7 +452,7 @@ onBeforeUnmount(() => {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: 22px;
border-radius: var(--radius, 10px);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
@@ -79,7 +79,7 @@ withDefaults(
.brand-copy strong {
font-size: 1.2rem;
color: #0f172a;
font-weight: 800;
font-weight: 600;
}
.brand-copy span {
@@ -117,7 +117,7 @@ withDefaults(
aspect-ratio: 1.35;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 18px;
border-radius: var(--radius);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.1);
padding: 1rem;
backdrop-filter: blur(6px);
@@ -150,7 +150,7 @@ withDefaults(
.chart-line {
height: 54px;
border-radius: 12px;
border-radius: var(--radius);
background:
linear-gradient(180deg, transparent 55%, rgba(13, 148, 136, 0.12) 55%),
radial-gradient(circle at 20% 60%, #14b8a6 3px, transparent 4px),
@@ -184,7 +184,7 @@ withDefaults(
position: absolute;
width: 42px;
height: 42px;
border-radius: 14px;
border-radius: var(--radius);
background: #fff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
}
@@ -224,7 +224,7 @@ function nodeClass(step: AutomationStep) {
width: 100%;
min-width: 0;
min-height: 22rem;
border-radius: 16px;
border-radius: var(--radius);
overflow: auto;
background-color: #f3f6f5;
background-image: radial-gradient(#c9d4cf 1.1px, transparent 1.2px);
@@ -254,7 +254,7 @@ function nodeClass(step: AutomationStep) {
gap: 0.7rem;
text-align: start;
padding: 0.72rem 0.8rem;
border-radius: 12px;
border-radius: var(--radius);
border: 1px solid #d5ddd8;
background: #fff;
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
@@ -273,7 +273,7 @@ function nodeClass(step: AutomationStep) {
}
.node.action:not(.condition) {
border-radius: 10px;
border-radius: var(--radius);
border-inline-start-width: 5px;
}
@@ -325,7 +325,7 @@ function nodeClass(step: AutomationStep) {
.icon {
width: 2.35rem;
height: 2.35rem;
border-radius: 12px;
border-radius: var(--radius);
display: grid;
place-items: center;
flex-shrink: 0;
@@ -350,7 +350,7 @@ function nodeClass(step: AutomationStep) {
}
.node.condition .icon {
border-radius: 8px;
border-radius: var(--radius);
color: #b45309;
background: #ffedd5;
}
@@ -369,7 +369,7 @@ function nodeClass(step: AutomationStep) {
strong {
font-size: 0.88rem;
font-weight: 800;
font-weight: 600;
color: #0f172a;
}
@@ -472,7 +472,7 @@ em {
margin-top: 1rem;
background: #fff;
border: 1px solid #d5ddd8;
border-radius: 14px;
border-radius: var(--radius);
padding: 0.85rem;
display: grid;
gap: 0.65rem;
@@ -278,14 +278,14 @@ watch(
padding: 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
border-radius: var(--radius);
min-width: 0;
}
h2 {
margin: 0;
font-size: 0.98rem;
font-weight: 800;
font-weight: 600;
}
.hint {
@@ -312,7 +312,7 @@ input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
color: #0f172a;
@@ -64,7 +64,7 @@ withDefaults(
width: max-content;
max-width: 118px;
padding: 0.48rem 0.65rem 0.55rem;
border-radius: 14px;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
font-size: 0.68rem;
+1 -1
View File
@@ -56,7 +56,7 @@ withDefaults(
letter-spacing: -0.02em;
color: #0f172a;
line-height: 1.1;
font-weight: 800;
font-weight: 600;
}
.text small {
@@ -93,7 +93,7 @@ function pick(label: string) {
<style scoped>
.panel {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
padding: 1rem 1rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -144,7 +144,7 @@ h2 {
align-items: center;
gap: 0.55rem;
padding: 0.58rem 0.7rem;
border-radius: 12px;
border-radius: var(--radius);
background: #fff;
border: 1px solid #eef2f7;
color: #334155;
@@ -168,7 +168,7 @@ h2 {
.chip-ico {
width: 26px;
height: 26px;
border-radius: 8px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
@@ -185,7 +185,7 @@ h2 {
.ai-input input {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: var(--radius);
padding: 0.58rem 0.75rem;
background: #f8fafc;
font-size: 0.8rem;
@@ -194,7 +194,7 @@ h2 {
.ai-input button {
width: 40px;
border: 0;
border-radius: 12px;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
cursor: pointer;
@@ -208,7 +208,7 @@ h2 {
.ai-answer {
background: #f0fdfa;
border-radius: 12px;
border-radius: var(--radius);
padding: 0.7rem 0.8rem;
margin-bottom: 0.75rem;
}
@@ -140,7 +140,7 @@ function alertIcon(tone: string) {
align-items: start;
min-height: 0;
padding: 0.8rem 1.5rem 0.9rem;
border-radius: 28px;
border-radius: var(--radius);
overflow: visible;
color: #fff;
background: transparent;
@@ -151,7 +151,7 @@ function alertIcon(tone: string) {
position: absolute;
inset: 0;
z-index: 0;
border-radius: 28px;
border-radius: var(--radius);
overflow: hidden;
background-color: #134e4a;
background-image:
@@ -251,7 +251,7 @@ function alertIcon(tone: string) {
.greet-text h1 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.75rem);
font-weight: 800;
font-weight: 600;
letter-spacing: -0.01em;
color: #fff;
}
@@ -276,7 +276,7 @@ function alertIcon(tone: string) {
gap: 0.4rem;
min-width: 0;
padding: 0.4rem 0.5rem;
border-radius: 12px;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
text-decoration: none;
@@ -311,7 +311,7 @@ function alertIcon(tone: string) {
.chip-ico {
width: 22px;
height: 22px;
border-radius: 8px;
border-radius: var(--radius);
display: grid;
place-items: center;
flex-shrink: 0;
@@ -369,7 +369,7 @@ function alertIcon(tone: string) {
color: #94a3b8;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 7px;
border-radius: var(--radius);
padding: 0.14rem 0.38rem;
flex-shrink: 0;
}
@@ -58,7 +58,7 @@ const iconTones: Record<string, string> = {
gap: 0.85rem;
align-items: flex-start;
background: #fff;
border-radius: 22px;
border-radius: var(--radius);
padding: 1.1rem 1.15rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -67,7 +67,7 @@ const iconTones: Record<string, string> = {
.kpi-icon {
width: 48px;
height: 48px;
border-radius: 16px;
border-radius: var(--radius);
background: #f8fafc;
display: grid;
place-items: center;
@@ -101,7 +101,7 @@ const cols = computed<Col[]>(() => {
<style scoped>
.panel {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -151,7 +151,7 @@ a {
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
border-radius: 12px;
border-radius: var(--radius);
background: #f8fafc;
border: 1px solid #eef2f7;
color: inherit;
@@ -185,12 +185,12 @@ a {
.deal-mark {
width: 28px;
height: 28px;
border-radius: 8px;
border-radius: var(--radius);
background: #ecfdf8;
color: #0f766e;
display: grid;
place-items: center;
font-weight: 800;
font-weight: 600;
flex-shrink: 0;
}
@@ -108,7 +108,7 @@ const rows = computed<Row[]>(() => {
<style scoped>
.panel {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -151,7 +151,7 @@ a {
.act-icon {
width: 38px;
height: 38px;
border-radius: 12px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
@@ -47,7 +47,7 @@ function trophy(rank: number) {
<style scoped>
.panel {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -89,12 +89,12 @@ a {
.cust-avatar {
width: 38px;
height: 38px;
border-radius: 12px;
border-radius: var(--radius);
background: linear-gradient(145deg, #ccfbf1, #99f6e4);
color: #0f766e;
display: grid;
place-items: center;
font-weight: 800;
font-weight: 600;
flex-shrink: 0;
}
@@ -775,7 +775,7 @@ onMounted(async () => {
gap: 0.28rem;
min-width: 0;
padding: 0.8rem 0.9rem;
border-radius: 12px;
border-radius: var(--radius);
border: 1px solid #e2e8f0;
background: #f8fafc;
}
@@ -789,7 +789,7 @@ onMounted(async () => {
align-items: baseline;
gap: 0.3rem;
font-size: 1.15rem;
font-weight: 800;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.balance-box em {
@@ -843,7 +843,7 @@ onMounted(async () => {
border: 1px solid var(--line);
background: var(--bg);
color: var(--text);
border-radius: 10px;
border-radius: var(--radius);
padding: 0.45rem 0.75rem;
cursor: pointer;
}
@@ -918,7 +918,7 @@ onMounted(async () => {
justify-content: space-between;
gap: 0.5rem;
padding: 0.3rem 0.4rem;
border-radius: 8px;
border-radius: var(--radius);
margin-top: 0.25rem;
font-size: 0.9rem;
}
@@ -942,21 +942,21 @@ onMounted(async () => {
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font-size: 0.88rem;
}
input,
select {
border: 1px solid var(--line);
border-radius: 10px;
border-radius: var(--radius);
padding: 0.45rem 0.6rem;
background: var(--bg);
color: var(--text);
}
.btn {
border: 0;
border-radius: 10px;
border-radius: var(--radius);
padding: 0.45rem 0.8rem;
background: var(--accent);
color: #fff;
@@ -119,7 +119,7 @@ withDefaults(
h3 {
margin: 0;
font-size: 1.08rem;
font-weight: 800;
font-weight: 600;
color: #1e2937;
}
@@ -145,7 +145,7 @@ function onSelect(key: string) {
height: 38px;
border: 0;
background: #fff;
border-radius: 12px;
border-radius: var(--radius);
display: grid;
place-items: center;
color: #334155;
@@ -175,7 +175,7 @@ function onSelect(key: string) {
list-style: none;
width: 220px;
background: #fff;
border-radius: 14px;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
@@ -193,7 +193,7 @@ function onSelect(key: string) {
box-sizing: border-box;
margin: 0;
border: 0;
border-radius: 10px;
border-radius: var(--radius);
background: transparent;
color: #0f172a;
font: inherit;
@@ -146,7 +146,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
background: var(--badge-bg);
color: var(--badge-fg);
font-size: 0.65rem;
font-weight: 800;
font-weight: 600;
line-height: 1;
}
@@ -163,7 +163,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
.titles h3 {
margin: 0;
font-size: 0.82rem;
font-weight: 800;
font-weight: 600;
line-height: 1.4;
color: var(--ink);
cursor: pointer;
@@ -230,7 +230,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
.amount strong {
font-size: 0.88rem;
font-weight: 800;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.01em;
max-width: 100%;
@@ -295,10 +295,10 @@ onBeforeUnmount(() => {
min-width: 0;
width: 100%;
font-size: 0.98rem;
font-weight: 800;
font-weight: 600;
color: var(--ink);
font: inherit;
font-weight: 800;
font-weight: 600;
}
.column-head h2 {
@@ -315,7 +315,7 @@ onBeforeUnmount(() => {
height: 1.7rem;
padding: 0 0.4rem;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
background: #fff;
}
@@ -389,7 +389,7 @@ onBeforeUnmount(() => {
color: var(--ink);
text-align: right;
cursor: pointer;
border-radius: 8px;
border-radius: var(--radius);
}
.col-menu button:hover:not(:disabled) {
@@ -233,7 +233,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
display: grid;
place-items: center;
font-size: 0.7rem;
font-weight: 800;
font-weight: 600;
}
.titles {
@@ -244,7 +244,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
.titles strong {
font-size: 0.92rem;
font-weight: 800;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
@@ -285,7 +285,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
}
.amount strong {
font-weight: 800;
font-weight: 600;
color: var(--ink);
}
@@ -314,7 +314,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
background: transparent;
color: var(--muted);
padding: 0.2rem;
border-radius: 8px;
border-radius: var(--radius);
cursor: pointer;
}
@@ -332,7 +332,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
.card {
background: #fff;
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
border-radius: 16px;
border-radius: var(--radius);
padding: 0.95rem 1rem;
display: grid;
gap: 0.7rem;
@@ -133,7 +133,7 @@ function onPick(value: string) {
h1 {
margin: 0;
font-size: 1.15rem;
font-weight: 800;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'neutral' | 'accent' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
size?: 'sm' | 'md';
dot?: boolean;
}>(),
{
variant: 'neutral',
size: 'md',
dot: false,
},
);
</script>
<template>
<span class="ui-badge" :class="[variant, size]">
<span v-if="dot" class="badge-dot" aria-hidden="true" />
<slot />
</span>
</template>
<style scoped>
.ui-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-full);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
line-height: 1.2;
white-space: nowrap;
border: 1px solid transparent;
user-select: none;
}
.ui-badge.sm {
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Variants */
.ui-badge.neutral {
background: var(--surface-subtle);
color: var(--ink-secondary);
border-color: var(--line);
}
.ui-badge.accent,
.ui-badge.primary {
background: var(--accent-soft);
color: var(--accent-deep);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
.ui-badge.success {
background: var(--success-soft);
color: var(--success-deep);
border-color: color-mix(in srgb, var(--success) 30%, transparent);
}
.ui-badge.warning {
background: var(--warning-soft);
color: var(--warning-deep);
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
}
.ui-badge.danger {
background: var(--danger-soft);
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
}
.ui-badge.info {
background: var(--info-soft);
color: var(--info-deep);
border-color: color-mix(in srgb, var(--info) 30%, transparent);
}
</style>
+158 -15
View File
@@ -1,12 +1,21 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'ghost' | 'dashed' | 'icon';
type?: 'button' | 'submit';
variant?: 'primary' | 'ghost' | 'dashed' | 'icon' | 'danger' | 'outline' | 'link' | 'secondary' | 'success';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
block?: boolean;
}>(),
{ variant: 'primary', type: 'button' },
{
variant: 'primary',
size: 'md',
type: 'button',
disabled: false,
loading: false,
block: false,
},
);
</script>
@@ -14,9 +23,12 @@ withDefaults(
<button
:type="type"
class="ui-btn"
:class="[variant, { block }]"
:disabled="disabled"
:class="[variant, size, { block, loading }]"
:disabled="disabled || loading"
:aria-busy="loading"
>
<span v-if="loading" class="spinner" aria-hidden="true"></span>
<slot name="icon" />
<slot />
</button>
</template>
@@ -26,18 +38,52 @@ withDefaults(
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
padding: 0 0.95rem;
gap: var(--sp-2);
min-height: var(--control-h-md);
height: var(--control-h-md);
padding: 0 0.85rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
color: var(--accent-contrast);
font: inherit;
font-weight: 700;
font-size: 0.88rem;
font-weight: var(--fw-semibold);
font-size: var(--fs-sm);
white-space: nowrap;
cursor: pointer;
box-sizing: border-box;
transition: background var(--transition-fast), border-color var(--transition-fast),
box-shadow var(--transition-fast), opacity var(--transition-fast);
user-select: none;
}
/* Sizes */
.ui-btn.sm {
min-height: var(--control-h-sm);
height: var(--control-h-sm);
padding: 0 0.65rem;
font-size: var(--fs-xs);
border-radius: var(--radius-sm);
}
.ui-btn.md {
min-height: var(--control-h-md);
height: var(--control-h-md);
padding: 0 0.85rem;
font-size: var(--fs-sm);
border-radius: var(--radius);
}
.ui-btn.lg {
min-height: var(--control-h-lg);
height: var(--control-h-lg);
padding: 0 1.15rem;
font-size: var(--fs-base);
border-radius: var(--radius-md);
}
.ui-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.ui-btn:disabled {
@@ -49,28 +95,125 @@ withDefaults(
width: 100%;
}
/* Variants */
.ui-btn.ghost {
background: #fff;
background: transparent;
color: var(--ink);
border-color: var(--line);
}
.ui-btn.ghost:hover:not(:disabled) {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.ui-btn.outline {
background: transparent;
color: var(--accent);
border-color: var(--accent);
}
.ui-btn.outline:hover:not(:disabled) {
background: var(--accent-subtle);
}
.ui-btn.secondary {
background: var(--surface-subtle);
color: var(--ink-secondary);
border-color: var(--line);
}
.ui-btn.secondary:hover:not(:disabled) {
background: var(--line);
color: var(--ink);
}
.ui-btn.danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.ui-btn.danger:hover:not(:disabled) {
background: var(--danger-hover);
}
.ui-btn.success {
background: var(--success);
color: #fff;
border-color: var(--success);
}
.ui-btn.success:hover:not(:disabled) {
background: var(--success-deep);
}
.ui-btn.dashed {
background: #fff;
border: 1.5px dashed currentColor;
font-weight: 700;
color: var(--muted);
border: 1.5px dashed var(--line-strong);
}
.ui-btn.dashed:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-subtle);
}
.ui-btn.link {
background: transparent;
border-color: transparent;
color: var(--accent);
padding: 0 var(--sp-1);
min-height: auto;
font-weight: var(--fw-semibold);
}
.ui-btn.link:hover:not(:disabled) {
background: transparent;
text-decoration: underline;
}
.ui-btn.icon {
width: var(--control-h);
width: var(--control-h-md);
padding: 0;
background: #fff;
color: var(--accent-deep);
border-color: var(--accent);
}
.ui-btn.icon.sm {
width: var(--control-h-sm);
min-height: var(--control-h-sm);
}
.ui-btn.icon.lg {
width: var(--control-h-lg);
min-height: var(--control-h-lg);
}
.ui-btn.icon:hover:not(:disabled) {
background: var(--accent-subtle);
}
.ui-btn:focus-visible {
outline: 2px solid var(--accent-soft);
outline-offset: 1px;
}
/* Spinner */
.spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ui-spin 0.6s linear infinite;
}
@keyframes ui-spin {
to {
transform: rotate(360deg);
}
}
</style>
+109 -4
View File
@@ -1,14 +1,119 @@
<script setup lang="ts">
withDefaults(
defineProps<{
padding?: 'none' | 'sm' | 'md' | 'lg';
shadow?: 'none' | 'xs' | 'sm' | 'md';
interactive?: boolean;
title?: string;
}>(),
{
padding: 'md',
shadow: 'xs',
interactive: false,
},
);
</script>
<template>
<div class="ui-card">
<slot />
<div
class="ui-card"
:class="[
`pad-${padding}`,
`shadow-${shadow}`,
{ interactive }
]"
>
<header v-if="title || $slots.header" class="card-head">
<slot name="header">
<h3 v-if="title" class="card-title">{{ title }}</h3>
</slot>
<div v-if="$slots.actions" class="card-actions">
<slot name="actions" />
</div>
</header>
<div class="card-body">
<slot />
</div>
<footer v-if="$slots.footer" class="card-foot">
<slot name="footer" />
</footer>
</div>
</template>
<style scoped>
.ui-card {
background: #fff;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--pad-modal);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
/* Padding variants */
.ui-card.pad-none {
padding: 0;
}
.ui-card.pad-sm {
padding: var(--sp-3);
}
.ui-card.pad-md {
padding: var(--sp-5);
}
.ui-card.pad-lg {
padding: var(--sp-6);
}
/* Shadow variants */
.ui-card.shadow-none {
box-shadow: none;
}
.ui-card.shadow-xs {
box-shadow: var(--shadow-xs);
}
.ui-card.shadow-sm {
box-shadow: var(--shadow-sm);
}
.ui-card.shadow-md {
box-shadow: var(--shadow-md);
}
.ui-card.interactive:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-sm);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
margin-bottom: var(--sp-4);
border-bottom: 1px solid var(--line-light);
}
.card-title {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-bold);
color: var(--ink);
}
.card-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.card-foot {
padding-top: var(--sp-3);
margin-top: var(--sp-4);
border-top: 1px solid var(--line-light);
}
</style>
+67 -7
View File
@@ -1,54 +1,114 @@
<script setup lang="ts">
<script lang="ts">
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart, PieChart } from 'echarts/charts';
import { LineChart, PieChart, BarChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent,
TitleComponent,
} from 'echarts/components';
import VChart from 'vue-echarts';
import type { ComposeOption } from 'echarts/core';
import type { LineSeriesOption, PieSeriesOption } from 'echarts/charts';
import type { LineSeriesOption, PieSeriesOption, BarSeriesOption } from 'echarts/charts';
import type {
GridComponentOption,
LegendComponentOption,
TooltipComponentOption,
TitleComponentOption,
} from 'echarts/components';
use([
CanvasRenderer,
LineChart,
PieChart,
BarChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
]);
export type UiChartOption = ComposeOption<
| LineSeriesOption
| PieSeriesOption
| BarSeriesOption
| GridComponentOption
| TooltipComponentOption
| LegendComponentOption
| TitleComponentOption
>;
/**
* Shared palette matching Montact CRM design tokens
*/
export const CHART_PALETTE = [
'#14b8a6', // Teal (Accent)
'#3b82f6', // Blue
'#f59e0b', // Amber
'#10b981', // Emerald
'#8b5cf6', // Violet
'#f43f5e', // Rose
'#06b6d4', // Cyan
'#eab308', // Yellow
];
</script>
<script setup lang="ts">
import VChart from 'vue-echarts';
withDefaults(
defineProps<{
option: UiChartOption;
height?: string;
loading?: boolean;
}>(),
{ height: '220px' },
{ height: '260px', loading: false },
);
</script>
<template>
<VChart class="ui-chart" :option="option" :style="{ height }" autoresize />
<div class="ui-chart-container" :style="{ height }">
<div v-if="loading" class="chart-loading">
<div class="chart-spinner"></div>
</div>
<VChart v-else class="ui-chart" :option="option" autoresize />
</div>
</template>
<style scoped>
.ui-chart {
.ui-chart-container {
width: 100%;
min-width: 0;
position: relative;
}
.ui-chart {
width: 100%;
height: 100%;
min-width: 0;
}
.chart-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.7);
z-index: 2;
}
.chart-spinner {
width: 28px;
height: 28px;
border: 3px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: chart-spin 0.8s linear infinite;
}
@keyframes chart-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
type IconName = InstanceType<typeof UiIcon>['$props']['name'];
withDefaults(
defineProps<{
title?: string;
description?: string;
icon?: IconName;
}>(),
{
title: 'موردی یافت نشد',
icon: 'search',
},
);
</script>
<template>
<div class="ui-empty-state">
<div class="icon-wrap">
<slot name="icon">
<UiIcon :name="icon" :size="32" />
</slot>
</div>
<h3 class="title">{{ title }}</h3>
<p v-if="description" class="description">{{ description }}</p>
<slot />
<div v-if="$slots.action" class="action-wrap">
<slot name="action" />
</div>
</div>
</template>
<style scoped>
.ui-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--sp-10) var(--sp-6);
gap: var(--sp-3);
color: var(--muted);
background: var(--surface);
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
width: 100%;
box-sizing: border-box;
}
.icon-wrap {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--surface-subtle);
color: var(--muted);
margin-bottom: var(--sp-1);
}
.title {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-bold);
color: var(--ink);
}
.description {
margin: 0;
font-size: var(--fs-sm);
max-width: 360px;
line-height: var(--lh-relaxed);
color: var(--muted);
}
.action-wrap {
margin-top: var(--sp-2);
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label?: string;
forId?: string;
required?: boolean;
hint?: string;
error?: string | boolean;
horizontal?: boolean;
}>(),
{
required: false,
horizontal: false,
},
);
</script>
<template>
<div class="ui-field" :class="{ horizontal, hasError: !!error }">
<div v-if="label || $slots.label" class="label-row">
<label v-if="label" :for="forId" class="field-label">
{{ label }}
<span v-if="required" class="required-mark" aria-hidden="true">*</span>
</label>
<slot v-else name="label" />
<slot name="label-extra" />
</div>
<div class="control-wrap">
<slot />
<p v-if="typeof error === 'string' && error" class="field-error" role="alert">
{{ error }}
</p>
<p v-else-if="hint || $slots.hint" class="field-hint">
<slot name="hint">{{ hint }}</slot>
</p>
</div>
</div>
</template>
<style scoped>
.ui-field {
display: flex;
flex-direction: column;
gap: var(--sp-1);
min-width: 0;
width: 100%;
}
.ui-field.horizontal {
flex-direction: row;
align-items: center;
gap: var(--sp-3);
}
.ui-field.horizontal .label-row {
flex: 0 0 140px;
}
.ui-field.horizontal .control-wrap {
flex: 1;
}
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.field-label {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
line-height: var(--lh-tight);
}
.required-mark {
color: var(--danger);
margin-right: 2px;
}
.control-wrap {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
width: 100%;
}
.field-error {
margin: 2px 0 0;
font-size: var(--fs-xs);
color: var(--danger);
font-weight: var(--fw-medium);
}
.field-hint {
margin: 2px 0 0;
font-size: var(--fs-xs);
color: var(--muted);
line-height: var(--lh-normal);
}
</style>
+3 -2
View File
@@ -21,7 +21,7 @@ const open = ref(false);
<div class="row">
<slot name="before" />
<UiSearch v-model="search" :placeholder="placeholder" class="search" />
<UiButton v-if="showFilters" type="button" @click="open = !open">
<UiButton v-if="showFilters" variant="secondary" type="button" @click="open = !open">
<UiIcon name="filter" :size="16" />
فیلترها
</UiButton>
@@ -54,9 +54,10 @@ const open = ref(false);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 0.65rem;
padding: var(--pad-modal);
padding: var(--sp-4);
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
</style>
+95 -21
View File
@@ -4,48 +4,122 @@ withDefaults(
type?: string;
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
required?: boolean;
maxlength?: number;
minlength?: number;
error?: boolean | string;
size?: 'sm' | 'md' | 'lg';
maxlength?: number | string;
minlength?: number | string;
id?: string;
autocomplete?: string;
}>(),
{ type: 'text' },
{
type: 'text',
size: 'md',
disabled: false,
readonly: false,
required: false,
},
);
const model = defineModel<string>({ default: '' });
const model = defineModel<string | number>({ default: '' });
</script>
<template>
<input
v-model="model"
class="ui-input"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:maxlength="maxlength"
:minlength="minlength"
/>
<div class="ui-input-wrapper" :class="[size, { disabled, hasError: !!error }]">
<slot name="prefix" />
<input
:id="id"
v-model="model"
class="ui-input"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:maxlength="maxlength"
:minlength="minlength"
:autocomplete="autocomplete"
:aria-invalid="!!error"
/>
<slot name="suffix" />
</div>
</template>
<style scoped>
.ui-input {
.ui-input-wrapper {
display: inline-flex;
align-items: center;
width: 100%;
min-width: 0;
min-height: var(--control-h);
padding: 0 0.85rem;
min-height: var(--control-h-md);
padding: 0 var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
background: var(--surface);
color: var(--ink);
font: inherit;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
box-sizing: border-box;
}
.ui-input:focus {
.ui-input-wrapper.sm {
min-height: var(--control-h-sm);
padding: 0 var(--sp-2);
border-radius: var(--radius-sm);
font-size: var(--fs-xs);
}
.ui-input-wrapper.md {
min-height: var(--control-h-md);
padding: 0 var(--sp-3);
border-radius: var(--radius);
font-size: var(--fs-base);
}
.ui-input-wrapper.lg {
min-height: var(--control-h-lg);
padding: 0 var(--sp-4);
border-radius: var(--radius-md);
font-size: var(--fs-md);
}
.ui-input {
flex: 1 1 auto;
width: 100%;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font: inherit;
padding: 0;
}
.ui-input::placeholder {
color: var(--muted-light);
}
.ui-input-wrapper:focus-within {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.ui-input-wrapper.hasError {
border-color: var(--danger);
}
.ui-input-wrapper.hasError:focus-within {
outline: 2px solid var(--danger-soft);
border-color: var(--danger);
}
.ui-input-wrapper.disabled {
background: var(--surface-subtle);
opacity: 0.65;
cursor: not-allowed;
}
.ui-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+89 -69
View File
@@ -61,7 +61,7 @@ function onBackdrop(event: MouseEvent) {
z-index: 80;
display: grid;
place-items: center;
padding: 1.25rem;
padding: 16px;
background: rgba(15, 23, 42, 0.35);
}
@@ -72,55 +72,122 @@ function onBackdrop(event: MouseEvent) {
flex-direction: column;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: var(--radius, 10px);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
overflow: hidden;
}
.head,
.body,
.foot {
padding: var(--pad-modal);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-bottom: 0;
padding: 16px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
background: #fff;
z-index: 10;
}
.head h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
font-size: 16px;
font-weight: 500;
color: var(--ink);
line-height: 1.4;
}
.close {
width: var(--control-h);
height: var(--control-h);
width: 36px;
height: 36px;
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: var(--radius, 10px);
background: #fff;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
transition: all var(--transition-fast);
}
.close:hover {
background: var(--surface-subtle);
color: var(--ink);
border-color: var(--line-strong);
}
.body {
overflow: auto;
flex: 1 1 auto;
min-height: 0;
padding-top: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 16px;
box-sizing: border-box;
}
.body :deep(.crm-page.embedded),
.body :deep(.page.embedded),
.body :deep(.product-form-page.embedded),
.body :deep(.company-form-page.embedded) {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 100%;
}
.body :deep(.page.embedded > .form),
.body :deep(.page.embedded > form),
.body :deep(.crm-page.embedded > .form),
.body :deep(.crm-page.embedded > form),
.body :deep(.crm-page.embedded > .crm-form) {
flex: 1 1 auto;
min-height: 0;
}
.body :deep(.embed-actions),
.body :deep(.embedded-actions),
.body :deep(.form-bottom-actions),
.body :deep(.form-footer-actions) {
order: 99;
flex-shrink: 0;
position: sticky;
bottom: -16px;
margin-top: auto;
margin-inline: -16px;
margin-bottom: -16px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 16px;
background: #fff;
border-top: 1px solid var(--line);
z-index: 10;
box-shadow: 0 -4px 14px rgba(15, 23, 42, 0.05);
}
.body :deep(.embed-actions .btn),
.body :deep(.embedded-actions .btn),
.body :deep(.embed-actions .ui-btn),
.body :deep(.embedded-actions .ui-btn),
.body :deep(.form-bottom-actions .btn),
.body :deep(.form-bottom-actions .ui-btn),
.body :deep(.form-footer-actions .btn),
.body :deep(.form-footer-actions .ui-btn) {
border-radius: var(--radius, 10px);
}
.foot {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding-top: 0;
border-top: 1px solid #f1f5f9;
gap: 12px;
padding: 16px;
border-top: 1px solid var(--line);
flex-shrink: 0;
background: #fff;
z-index: 10;
}
@media (max-width: 900px) {
@@ -138,62 +205,15 @@ function onBackdrop(event: MouseEvent) {
}
.head {
flex-shrink: 0;
padding: 0.75rem 1rem 0;
padding: 16px;
}
.body {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
padding: 0.75rem 1rem;
}
.body:has(.page.embedded) {
overflow: hidden;
padding-bottom: 0;
}
.body :deep(.page.embedded) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 0.75rem;
}
.body :deep(.page.embedded > .form),
.body :deep(.page.embedded > form) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.body :deep(.embed-actions) {
order: 20;
flex: 0 0 auto;
position: sticky;
bottom: 0;
margin-top: auto;
margin-inline: -1rem;
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #f1f5f9;
z-index: 3;
}
.body :deep(.embed-actions .btn) {
flex: 1;
min-width: 0;
padding: 16px;
}
.foot {
flex-shrink: 0;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
padding: 16px calc(16px + env(safe-area-inset-bottom));
}
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
withDefaults(
defineProps<{
title?: string;
lead?: string;
description?: string;
backTo?: string | object;
}>(),
{},
);
</script>
<template>
<header class="ui-page-header">
<div class="header-main">
<RouterLink v-if="backTo" :to="backTo" class="back-link" title="بازگشت">
<UiIcon name="arrow-right" :size="18" />
</RouterLink>
<div class="titles">
<h1 v-if="title" class="title">{{ title }}</h1>
<slot v-else name="title" />
<p v-if="lead || description" class="lead">{{ lead || description }}</p>
<slot v-else name="lead" />
</div>
</div>
<div v-if="$slots.actions" class="actions">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.ui-page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-4);
min-width: 0;
width: 100%;
}
.header-main {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.back-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-h-md);
height: var(--control-h-md);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-secondary);
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.back-link:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
color: var(--accent-deep);
}
.titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
margin: 0;
font-size: var(--fs-2xl);
font-weight: var(--fw-extrabold);
color: var(--ink);
line-height: var(--lh-tight);
}
.lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: var(--lh-normal);
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
@media (max-width: 900px) {
.ui-page-header {
flex-direction: column;
align-items: stretch;
gap: var(--sp-3);
}
.title {
font-size: var(--fs-xl);
}
.actions {
justify-content: stretch;
}
.actions > * {
flex: 1 1 auto;
}
}
</style>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiButton from './UiButton.vue';
import UiIcon from '@/components/UiIcon.vue';
const props = withDefaults(
defineProps<{
page: number;
totalPages?: number;
totalItems?: number;
pageSize?: number;
}>(),
{
page: 1,
},
);
const emit = defineEmits<{
'update:page': [page: number];
change: [page: number];
}>();
const effectiveTotalPages = computed(() => {
if (props.totalPages != null) return Math.max(1, props.totalPages);
if (props.totalItems != null && props.pageSize != null && props.pageSize > 0) {
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
}
return 1;
});
function goTo(p: number) {
if (p < 1 || p > effectiveTotalPages.value || p === props.page) return;
emit('update:page', p);
emit('change', p);
}
</script>
<template>
<nav v-if="effectiveTotalPages > 1 || totalItems !== undefined" class="ui-pagination" aria-label="صفحه‌بندی">
<div class="pagination-info">
<span v-if="totalItems !== undefined">
مجموع: <strong>{{ totalItems }}</strong> مورد
</span>
<span v-if="effectiveTotalPages > 1" class="page-count">
(صفحه {{ page }} از {{ effectiveTotalPages }})
</span>
</div>
<div v-if="effectiveTotalPages > 1" class="pagination-controls">
<UiButton
variant="ghost"
size="sm"
:disabled="page <= 1"
title="صفحه اول"
@click="goTo(1)"
>
<UiIcon name="chevrons-right" :size="14" />
</UiButton>
<UiButton
variant="ghost"
size="sm"
:disabled="page <= 1"
title="صفحه قبل"
@click="goTo(page - 1)"
>
<UiIcon name="chevron-right" :size="14" />
قبلی
</UiButton>
<span class="current-indicator">
{{ page }} / {{ effectiveTotalPages }}
</span>
<UiButton
variant="ghost"
size="sm"
:disabled="page >= effectiveTotalPages"
title="صفحه بعد"
@click="goTo(page + 1)"
>
بعدی
<UiIcon name="chevron-left" :size="14" />
</UiButton>
<UiButton
variant="ghost"
size="sm"
:disabled="page >= effectiveTotalPages"
title="صفحه آخر"
@click="goTo(effectiveTotalPages)"
>
<UiIcon name="chevrons-left" :size="14" />
</UiButton>
</div>
</nav>
</template>
<style scoped>
.ui-pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
padding-top: var(--sp-4);
width: 100%;
}
.pagination-info {
font-size: var(--fs-sm);
color: var(--muted);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.page-count {
color: var(--muted-light);
}
.pagination-controls {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
}
.current-indicator {
padding: 0 var(--sp-2);
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
color: var(--ink-secondary);
}
@media (max-width: 900px) {
.ui-pagination {
flex-direction: column;
align-items: center;
gap: var(--sp-3);
}
}
</style>
+8 -2
View File
@@ -29,12 +29,15 @@ const model = defineModel<string>({ default: '' });
align-items: center;
gap: 0.45rem;
min-width: 0;
height: var(--control-h);
min-height: var(--control-h);
padding: 0 0.75rem;
background: #fff;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--muted);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.ui-search input {
@@ -45,11 +48,14 @@ const model = defineModel<string>({ default: '' });
background: transparent;
color: var(--ink);
font: inherit;
font-size: var(--fs-sm);
padding: 0;
}
.ui-search:focus-within {
outline: 2px solid var(--accent-soft);
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.sr {
+61 -10
View File
@@ -1,19 +1,30 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
defineProps<{
disabled?: boolean;
}>();
withDefaults(
defineProps<{
disabled?: boolean;
error?: boolean | string;
size?: 'sm' | 'md' | 'lg';
id?: string;
required?: boolean;
}>(),
{
disabled: false,
size: 'md',
required: false,
},
);
const model = defineModel<string>({ default: '' });
</script>
<template>
<label class="ui-select">
<select v-model="model" :disabled="disabled">
<label class="ui-select" :class="[size, { disabled, hasError: !!error }]">
<select :id="id" v-model="model" :disabled="disabled" :required="required" :aria-invalid="!!error">
<slot />
</select>
<UiIcon class="chev" name="chevron" :size="16" />
<UiIcon class="chev" name="chevron" :size="size === 'sm' ? 14 : 16" />
</label>
</template>
@@ -22,20 +33,45 @@ const model = defineModel<string>({ default: '' });
position: relative;
display: block;
min-width: 0;
width: 100%;
}
.ui-select select {
appearance: none;
width: 100%;
min-height: var(--control-h);
padding: 0 2rem 0 0.85rem;
min-height: var(--control-h-md);
padding: 0 2rem 0 var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
background: var(--surface);
color: var(--ink);
font: inherit;
font-weight: 600;
font-weight: var(--fw-medium);
font-size: var(--fs-base);
cursor: pointer;
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.ui-select.sm select {
min-height: var(--control-h-sm);
padding: 0 1.75rem 0 var(--sp-2);
border-radius: var(--radius-sm);
font-size: var(--fs-xs);
}
.ui-select.md select {
min-height: var(--control-h-md);
padding: 0 2rem 0 var(--sp-3);
border-radius: var(--radius);
font-size: var(--fs-base);
}
.ui-select.lg select {
min-height: var(--control-h-lg);
padding: 0 2.25rem 0 var(--sp-4);
border-radius: var(--radius-md);
font-size: var(--fs-md);
}
.ui-select select:focus {
@@ -43,6 +79,21 @@ const model = defineModel<string>({ default: '' });
border-color: var(--accent);
}
.ui-select.hasError select {
border-color: var(--danger);
}
.ui-select.hasError select:focus {
outline: 2px solid var(--danger-soft);
border-color: var(--danger);
}
.ui-select.disabled select {
background: var(--surface-subtle);
opacity: 0.65;
cursor: not-allowed;
}
.chev {
position: absolute;
left: 0.65rem;
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
withDefaults(
defineProps<{
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
required?: boolean;
error?: boolean | string;
rows?: number;
maxlength?: number;
id?: string;
}>(),
{
rows: 3,
disabled: false,
readonly: false,
required: false,
},
);
const model = defineModel<string>({ default: '' });
</script>
<template>
<textarea
:id="id"
v-model="model"
class="ui-textarea"
:class="{ disabled, hasError: !!error }"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:rows="rows"
:maxlength="maxlength"
:aria-invalid="!!error"
/>
</template>
<style scoped>
.ui-textarea {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: var(--fs-base);
line-height: var(--lh-normal);
resize: vertical;
min-height: 80px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.ui-textarea::placeholder {
color: var(--muted-light);
}
.ui-textarea:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.ui-textarea.hasError {
border-color: var(--danger);
}
.ui-textarea.hasError:focus {
outline: 2px solid var(--danger-soft);
border-color: var(--danger);
}
.ui-textarea.disabled {
background: var(--surface-subtle);
opacity: 0.65;
cursor: not-allowed;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
export { default as UiButton } from './UiButton.vue';
export { default as UiInput } from './UiInput.vue';
export { default as UiTextarea } from './UiTextarea.vue';
export { default as UiSelect } from './UiSelect.vue';
export { default as UiField } from './UiField.vue';
export { default as UiCard } from './UiCard.vue';
export { default as UiModal } from './UiModal.vue';
export { default as UiPageHeader } from './UiPageHeader.vue';
export { default as UiBadge } from './UiBadge.vue';
export { default as UiPagination } from './UiPagination.vue';
export { default as UiEmptyState } from './UiEmptyState.vue';
export { default as UiSearch } from './UiSearch.vue';
export { default as UiFilterBar } from './UiFilterBar.vue';
export { default as UiSegmented } from './UiSegmented.vue';
export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue';