Unify the web UI on shared controls and let pipeline columns be managed on the board.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 01:22:24 +03:30
co-authored by Cursor
parent f1aba1b386
commit b8ad4037b6
40 changed files with 2334 additions and 788 deletions
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'ghost' | 'dashed' | 'icon';
type?: 'button' | 'submit';
disabled?: boolean;
block?: boolean;
}>(),
{ variant: 'primary', type: 'button' },
);
</script>
<template>
<button
:type="type"
class="ui-btn"
:class="[variant, { block }]"
:disabled="disabled"
>
<slot />
</button>
</template>
<style scoped>
.ui-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
padding: 0 0.95rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font: inherit;
font-weight: 700;
font-size: 0.88rem;
white-space: nowrap;
cursor: pointer;
}
.ui-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ui-btn.block {
width: 100%;
}
.ui-btn.ghost {
background: #fff;
color: var(--ink);
border-color: var(--line);
}
.ui-btn.dashed {
background: #fff;
border: 1.5px dashed currentColor;
font-weight: 700;
}
.ui-btn.icon {
width: var(--control-h);
padding: 0;
background: #fff;
color: var(--accent-deep);
border-color: var(--accent);
}
.ui-btn:focus-visible {
outline: 2px solid var(--accent-soft);
outline-offset: 1px;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="ui-card">
<slot />
</div>
</template>
<style scoped>
.ui-card {
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--pad-modal);
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart, PieChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
import VChart from 'vue-echarts';
import type { ComposeOption } from 'echarts/core';
import type { LineSeriesOption, PieSeriesOption } from 'echarts/charts';
import type {
GridComponentOption,
LegendComponentOption,
TooltipComponentOption,
} from 'echarts/components';
use([
CanvasRenderer,
LineChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,
]);
export type UiChartOption = ComposeOption<
| LineSeriesOption
| PieSeriesOption
| GridComponentOption
| TooltipComponentOption
| LegendComponentOption
>;
withDefaults(
defineProps<{
option: UiChartOption;
height?: string;
}>(),
{ height: '220px' },
);
</script>
<template>
<VChart class="ui-chart" :option="option" :style="{ height }" autoresize />
</template>
<style scoped>
.ui-chart {
width: 100%;
min-width: 0;
}
</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from 'vue';
import UiButton from './UiButton.vue';
import UiSearch from './UiSearch.vue';
import UiIcon from '@/components/UiIcon.vue';
withDefaults(
defineProps<{
placeholder?: string;
}>(),
{ placeholder: 'جستجو...' },
);
const search = defineModel<string>('search', { default: '' });
const open = ref(false);
</script>
<template>
<div class="filter-bar">
<div class="row">
<slot name="before" />
<UiSearch v-model="search" :placeholder="placeholder" class="search" />
<UiButton type="button" @click="open = !open">
<UiIcon name="filter" :size="16" />
فیلترها
</UiButton>
</div>
<div v-if="open" class="panel">
<slot />
</div>
</div>
</template>
<style scoped>
.filter-bar {
display: grid;
gap: 0.65rem;
min-width: 0;
}
.row {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.search {
flex: 1 1 10rem;
}
.panel {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 0.65rem;
padding: var(--pad-modal);
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
withDefaults(
defineProps<{
type?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
maxlength?: number;
minlength?: number;
}>(),
{ type: 'text' },
);
const model = defineModel<string>({ default: '' });
</script>
<template>
<input
v-model="model"
class="ui-input"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:maxlength="maxlength"
:minlength="minlength"
/>
</template>
<style scoped>
.ui-input {
width: 100%;
min-width: 0;
min-height: var(--control-h);
padding: 0 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
font: inherit;
}
.ui-input:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.ui-input:disabled {
opacity: 0.6;
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { onUnmounted, watch } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
open: boolean;
title: string;
}>();
const emit = defineEmits<{
close: [];
}>();
watch(
() => props.open,
(open) => {
document.body.style.overflow = open ? 'hidden' : '';
},
);
onUnmounted(() => {
document.body.style.overflow = '';
});
function onBackdrop(event: MouseEvent) {
if (event.target === event.currentTarget) emit('close');
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="backdrop" @click="onBackdrop">
<section
class="dialog"
role="dialog"
aria-modal="true"
:aria-label="title"
@click.stop
>
<header class="head">
<h2>{{ title }}</h2>
<button type="button" class="close" aria-label="بستن" @click="emit('close')">
<UiIcon name="x" :size="18" />
</button>
</header>
<div class="body">
<slot />
</div>
<footer v-if="$slots.footer" class="foot">
<slot name="footer" />
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.25rem;
background: rgba(15, 23, 42, 0.35);
}
.dialog {
width: min(880px, 100%);
max-height: min(88vh, 880px);
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
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;
}
.head h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
}
.close {
width: var(--control-h);
height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.body {
overflow: auto;
min-height: 0;
padding-top: 1rem;
}
.foot {
display: flex;
justify-content: flex-start;
gap: 0.5rem;
padding-top: 0;
border-top: 1px solid #f1f5f9;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
withDefaults(
defineProps<{
placeholder?: string;
}>(),
{ placeholder: 'جستجو...' },
);
const model = defineModel<string>({ default: '' });
</script>
<template>
<label class="ui-search">
<span class="sr">جستجو</span>
<input
v-model="model"
type="search"
:placeholder="placeholder"
/>
<UiIcon name="search" :size="16" />
</label>
</template>
<style scoped>
.ui-search {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
min-height: var(--control-h);
padding: 0 0.75rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--muted);
}
.ui-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: var(--ink);
font: inherit;
}
.ui-search:focus-within {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts" generic="T extends string">
defineProps<{
options: Array<{ value: T; label: string; icon?: 'grid' | 'list' }>;
}>();
const model = defineModel<T>({ required: true });
</script>
<template>
<div class="ui-seg" role="group">
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="seg"
:class="{ on: model === opt.value }"
:aria-pressed="model === opt.value"
@click="model = opt.value"
>
<slot name="icon" :option="opt" />
<span class="seg-label">{{ opt.label }}</span>
</button>
</div>
</template>
<style scoped>
.ui-seg {
display: inline-flex;
align-items: center;
min-height: var(--control-h);
padding: 3px;
gap: 2px;
background: #e8eef1;
border-radius: var(--radius);
}
.seg {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: calc(var(--control-h) - 6px);
padding: 0 0.75rem;
border: 0;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: #7b8794;
font: inherit;
font-weight: 700;
font-size: 0.82rem;
cursor: pointer;
white-space: nowrap;
}
.seg.on {
background: #fff;
color: var(--accent-deep);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.seg:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
defineProps<{
disabled?: boolean;
}>();
const model = defineModel<string>({ default: '' });
</script>
<template>
<label class="ui-select">
<select v-model="model" :disabled="disabled">
<slot />
</select>
<UiIcon class="chev" name="chevron" :size="16" />
</label>
</template>
<style scoped>
.ui-select {
position: relative;
display: block;
min-width: 0;
}
.ui-select select {
appearance: none;
width: 100%;
min-height: var(--control-h);
padding: 0 2rem 0 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.ui-select select:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.chev {
position: absolute;
left: 0.65rem;
top: 50%;
color: var(--muted);
pointer-events: none;
transform: translateY(-50%) rotate(-90deg);
}
</style>