@@ -1,10 +1,59 @@
< script setup lang = "ts" >
import { computed , onMounted , ref } from 'vue' ;
import { computed , ref , watch } from 'vue' ;
import { useRoute , useRouter } from 'vue-router' ;
import axios from 'axios' ;
import type { DeletionArchiveListResponse , DeletionRetention } from '@mento/shared' ;
import type {
AuditLogDto ,
AuditLogListResponse ,
DeletionArchiveListResponse ,
DeletionRetention ,
} from '@mento/shared' ;
import { api } from '@/lib/api' ;
import { formatJalaliDateTime , toFaDigits } from '@/lib/jalali' ;
import { UiButton , UiCard , UiEmptyState , UiModal , UiPageHeader } from '@/components/ui' ;
import { UiButton , UiCard , UiEmptyState , UiModal , UiPageHeader , UiPagination } from '@/components/ui' ;
import { PAGE _SIZE _OPTIONS , usePageSize } from '@/lib/pageSize' ;
type HistoryTab = 'creates' | 'edits' | 'deletes' ;
const TABS : Array < { key : HistoryTab ; label : string } > = [
{ key : 'creates' , label : 'ایجاد' } ,
{ key : 'edits' , label : 'ویرایش' } ,
{ key : 'deletes' , label : 'حذف' } ,
] ;
const ENTITY _LABELS : Record < string , string > = {
people : 'مشتری' ,
companies : 'شرکت' ,
leads : 'لید' ,
deals : 'معامله' ,
activities : 'فعالیت' ,
notes : 'یادداشت' ,
tickets : 'تیکت' ,
quotes : 'پیشفاکتور' ,
invoices : 'فاکتور' ,
products : 'محصول' ,
'project-cards' : 'کارت پروژه' ,
'project-boards' : 'تخته پروژه' ,
pipelines : 'مسیر فروش' ,
routines : 'روتین' ,
'routine-tasks' : 'کار روتین' ,
'lead-forms' : 'فرم' ,
users : 'کاربر' ,
teams : 'تیم' ,
automations : 'اتوماسیون' ,
'message-automations' : 'پیام خودکار' ,
'sms-campaigns' : 'کمپین پیامک' ,
'custom-fields' : 'فیلد سفارشی' ,
inventory : 'انبار' ,
reservations : 'رزرو' ,
organizations : 'سازمان' ,
faq : 'پرسش متداول' ,
conversations : 'گفتگو' ,
loyalty : 'باشگاه' ,
notifications : 'اعلان' ,
'table-prefs' : 'ستونهای جدول' ,
integrations : 'اتصال' ,
} ;
const RETENTION _OPTIONS : Array < { value : DeletionRetention ; label : string } > = [
{ value : 'month' , label : 'یک ماه' } ,
@@ -15,22 +64,42 @@ const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
{ value : 'never' , label : 'هرگز' } ,
] ;
const route = useRoute ( ) ;
const router = useRouter ( ) ;
const loading = ref ( false ) ;
const savingRetention = ref ( false ) ;
const busyId = ref ( '' ) ;
const error = ref ( '' ) ;
const okMsg = ref ( '' ) ;
const q = ref ( '' ) ;
const actorId = ref ( '' ) ;
const actors = ref < Array < { id : string ; fullName : string } > > ( [ ] ) ;
const page = ref ( 1 ) ;
const pageSize = usePageSize ( 'history-log' ) ;
const retention = ref < DeletionRetention > ( 'never' ) ;
const data = ref < DeletionArchiveListResponse | null > ( null ) ;
const audit = ref < AuditLogListResponse | null > ( null ) ;
const purgeId = ref < string | null > ( null ) ;
const totalPages = computed ( ( ) => {
if ( ! data . value ) return 1 ;
return Math . max ( 1 , Math . ceil ( data . value . total / data . value . limit ) ) ;
const tab = computed < HistoryTab > ( ( ) => {
const value = route . query . tab ;
if ( value === 'creates' || value === 'edits' || value === 'deletes' ) return value ;
return 'deletes' ;
} ) ;
const totalPages = computed ( ( ) => {
const source = tab . value === 'deletes' ? data . value : audit . value ;
if ( ! source ) return 1 ;
return Math . max ( 1 , Math . ceil ( source . total / source . limit ) ) ;
} ) ;
const listSource = computed ( ( ) => ( tab . value === 'deletes' ? data . value : audit . value ) ) ;
function rowNo ( index : number ) {
return ( page . value - 1 ) * pageSize . value + index + 1 ;
}
const purgeItem = computed ( ( ) => data . value ? . items . find ( ( item ) => item . id === purgeId . value ) ? ? null ) ;
function apiError ( e : unknown , fallback : string ) {
@@ -43,20 +112,294 @@ function apiError(e: unknown, fallback: string) {
return fallback ;
}
function entityLabel ( entityType : string | null ) {
if ( ! entityType ) return 'مورد' ;
return ENTITY _LABELS [ entityType ] ? ? entityType ;
}
function looksLikeId ( id : string | null | undefined ) {
return ! ! id && /^c[a-z0-9]{20,}$/i . test ( id ) ;
}
const ENTITY _GET : Record < string , ( id : string ) = > string > = {
people : ( id ) => ` /people/ ${ id } ` ,
companies : ( id ) => ` /companies/ ${ id } ` ,
leads : ( id ) => ` /leads/ ${ id } ` ,
deals : ( id ) => ` /deals/ ${ id } ` ,
activities : ( id ) => ` /activities/ ${ id } ` ,
notes : ( id ) => ` /notes/ ${ id } ` ,
tickets : ( id ) => ` /tickets/ ${ id } ` ,
quotes : ( id ) => ` /quotes/ ${ id } ` ,
invoices : ( id ) => ` /invoices/ ${ id } ` ,
products : ( id ) => ` /products/ ${ id } ` ,
'project-cards' : ( id ) => ` /project-cards/ ${ id } ` ,
'lead-forms' : ( id ) => ` /lead-forms/ ${ id } ` ,
routines : ( id ) => ` /routines/ ${ id } ` ,
} ;
const recordInfo = ref < Record < string , { title : string ; snippet : string } > > ( { } ) ;
function entityKey ( row : AuditLogDto ) {
if ( ! row . entityType || ! looksLikeId ( row . entityId ) ) return '' ;
return ` ${ row . entityType } : ${ row . entityId } ` ;
}
function pathTail ( row : AuditLogDto ) {
const parts = row . path . replace ( /^\/api\/?/ , '' ) . split ( '/' ) . filter ( Boolean ) ;
const second = parts [ 1 ] ? ? '' ;
return looksLikeId ( second ) ? parts . slice ( 2 ) . join ( '/' ) : parts . slice ( 1 ) . join ( '/' ) ;
}
function pathParts ( row : AuditLogDto ) {
return row . path . replace ( /^\/api\/?/ , '' ) . split ( '/' ) . filter ( Boolean ) ;
}
function pathPhrase ( row : AuditLogDto ) {
const parts = pathParts ( row ) ;
const tail = pathTail ( row ) ;
const kind = entityLabel ( row . entityType ) ;
const nested = ! looksLikeId ( parts [ 1 ] ) ;
if ( nested && parts [ 0 ] === 'activities' && parts [ 1 ] === 'types' ) {
return parts [ 2 ] === 'reorder' ? 'ترتیب انواع فعالیت عوض شد' : 'نوع فعالیت ویرایش شد' ;
}
if ( nested && parts [ 0 ] === 'activities' && parts [ 1 ] === 'stages' ) {
return parts [ 2 ] === 'reorder' ? 'ترتیب مراحل فعالیت عوض شد' : 'مرحله فعالیت ویرایش شد' ;
}
if ( parts [ 0 ] === 'table-prefs' ) return 'چینش ستونهای جدول عوض شد' ;
if ( parts [ 0 ] === 'notifications' ) return 'اعلانها خوانده شد' ;
if ( parts [ 0 ] === 'integrations' && parts [ 1 ] === 'tahesab' ) return 'تنظیم اتصال تهحساب ویرایش شد' ;
if ( parts [ 0 ] === 'integrations' && parts [ 1 ] === 'woocommerce' ) return 'تنظیم ووکامرس ویرایش شد' ;
if ( parts [ 0 ] === 'integrations' && parts [ 1 ] === 'montify' ) return 'تنظیم مونتیفای ویرایش شد' ;
if ( parts [ 0 ] === 'integrations' && parts [ 1 ] === 'voip' ) return 'تنظیم تلفن ویرایش شد' ;
if ( tail === 'import' || tail . startsWith ( 'import/' ) ) {
return kind === 'مشتری' ? 'ورود مشتریان از فایل' : ` ورود ${ kind } از فایل ` ;
}
const phrases : Record < string , string > = {
sms : 'ارسال پیامک' ,
'birthday-sms' : 'پیامک تبریک تولد' ,
complete : 'تکمیل شد' ,
stage : 'مرحله عوض شد' ,
move : 'در مسیر جابهجا شد' ,
notes : 'یادداشت ثبت شد' ,
call : 'تماس ثبت شد' ,
'follow-up' : 'پیگیری ثبت شد' ,
convert : 'به مشتری تبدیل شد' ,
logo : 'تصویر عوض شد' ,
assign : 'واگذار شد' ,
'bulk-stage' : 'مرحله چند مورد با هم عوض شد' ,
'bulk-delete' : 'حذف گروهی' ,
'auto-assign' : 'واگذاری خودکار' ,
archive : 'بایگانی شد' ,
} ;
if ( phrases [ tail ] ) return phrases [ tail ] ;
if ( row . method === 'POST' ) return ` ${ kind } ساخته شد ` ;
if ( row . method === 'DELETE' ) return ` ${ kind } حذف شد ` ;
return ` مشخصات ${ kind } ویرایش شد ` ;
}
function pickInfo ( type : string , body : Record < string , unknown > ) {
const title =
type === 'people'
? [ body . firstName , body . lastName ] . filter ( ( part ) => typeof part === 'string' && part . trim ( ) ) . join ( ' ' )
: String ( body . title || body . name || body . subject || body . number || '' ) . trim ( ) ;
const raw = String ( body . description || body . body || '' )
. split ( '\n' )
. map ( ( line ) => line . trim ( ) )
. find ( Boolean ) ? ? '' ;
const snippet = raw . replace ( /\s+/g , ' ' ) . slice ( 0 , 180 ) ;
return {
title : title . slice ( 0 , 120 ) ,
snippet : snippet && snippet !== title ? snippet : '' ,
} ;
}
async function hydrate ( rows : AuditLogDto [ ] ) {
const pending = new Map < string , { type : string ; id : string } > ( ) ;
for ( const row of rows ) {
const key = entityKey ( row ) ;
if ( ! key || recordInfo . value [ key ] || pending . has ( key ) ) continue ;
const type = row . entityType ? ? '' ;
if ( ! ENTITY _GET [ type ] || ! row . entityId ) continue ;
pending . set ( key , { type , id : row . entityId } ) ;
}
await Promise . all (
[ ... pending . entries ( ) ] . map ( async ( [ key , job ] ) => {
try {
const { data : body } = await api . get < Record < string , unknown > > ( ENTITY _GET [ job . type ] ( job . id ) ) ;
recordInfo . value = { ... recordInfo . value , [ key ] : pickInfo ( job . type , body ? ? { } ) } ;
} catch {
recordInfo . value = { ... recordInfo . value , [ key ] : { title : '' , snippet : '' } } ;
}
} ) ,
) ;
}
function rowHeading ( row : AuditLogDto ) {
if ( row . label ) return row . label ;
const cached = recordInfo . value [ entityKey ( row ) ] ? . title ;
if ( cached ) return cached ;
const phrase = pathPhrase ( row ) ;
if ( ! looksLikeId ( row . entityId ) ) return phrase ;
return entityLabel ( row . entityType ) ;
}
function rowDetail ( row : AuditLogDto ) {
if ( row . detail ) return row . detail ;
const heading = rowHeading ( row ) ;
const phrase = pathPhrase ( row ) ;
const snippet = recordInfo . value [ entityKey ( row ) ] ? . snippet ? ? '' ;
if ( snippet ) return snippet ;
if ( phrase && phrase !== heading ) return phrase ;
return '' ;
}
function recordPath ( row : AuditLogDto ) {
const parts = pathParts ( row ) ;
const settings : Record < string , string > = {
tahesab : '/settings/tahesab' ,
woocommerce : '/settings/woocommerce' ,
montify : '/settings/montify' ,
voip : '/settings/voip' ,
} ;
if ( parts [ 0 ] === 'integrations' && settings [ parts [ 1 ] ? ? '' ] ) return settings [ parts [ 1 ] ? ? '' ] ;
if ( parts [ 0 ] === 'automations' ) return '/settings/automations' ;
if ( parts [ 0 ] === 'message-automations' ) return '/settings/message-automations' ;
if ( parts [ 0 ] === 'custom-fields' ) return '/settings/custom-fields' ;
if ( parts [ 0 ] === 'faq' ) return '/settings/faq' ;
if ( parts [ 0 ] === 'pipelines' ) return '/settings/pipelines' ;
if ( parts [ 0 ] === 'users' || parts [ 0 ] === 'teams' || parts [ 0 ] === 'org-roles' ) return '/settings/team' ;
const entityType = row . entityType ;
const entityId = looksLikeId ( parts [ 1 ] ) ? parts [ 1 ] : looksLikeId ( row . entityId ) ? row . entityId : null ;
if ( ! entityId || ! entityType ) return null ;
const routes : Record < string , string > = {
people : ` /contacts/people/ ${ entityId } ` ,
companies : ` /contacts/companies/ ${ entityId } ` ,
leads : ` /leads/ ${ entityId } ` ,
deals : ` /deals/ ${ entityId } ` ,
activities : ` /activities/ ${ entityId } ` ,
tickets : ` /tickets/ ${ entityId } ` ,
quotes : ` /quotes/ ${ entityId } ` ,
invoices : ` /invoices/ ${ entityId } ` ,
products : ` /settings/products/ ${ entityId } /edit ` ,
'project-cards' : ` /project-cards/ ${ entityId } ` ,
routines : ` /routines/manage/ ${ entityId } /edit ` ,
'lead-forms' : ` /settings/lead-forms/ ${ entityId } ` ,
} ;
return routes [ entityType ] ? ? null ;
}
function openRecord ( row : AuditLogDto ) {
const path = recordPath ( row ) ;
if ( path ) void router . push ( path ) ;
}
async function loadAudit ( current : 'creates' | 'edits' ) {
const action = current === 'creates' ? 'ایجاد' : 'ویرایش' ;
const search = q . value . trim ( ) ;
try {
const { data : res } = await api . get < AuditLogListResponse > ( '/audit-logs' , {
params : {
q : search || undefined ,
action ,
actorUserId : actorId . value || undefined ,
page : page . value ,
limit : pageSize . value ,
} ,
} ) ;
return res ;
} catch ( e ) {
if ( ! axios . isAxiosError ( e ) || e . response ? . status !== 400 ) throw e ;
const { data : res } = await api . get < AuditLogListResponse > ( '/audit-logs' , {
params : {
q : search || action ,
actorUserId : actorId . value || undefined ,
page : page . value ,
limit : pageSize . value ,
} ,
} ) ;
return { ... res , items : res . items . filter ( ( item ) => item . action === action ) } ;
}
}
async function loadDeletes ( ) {
const params = {
q : q . value . trim ( ) || undefined ,
deletedById : actorId . value || undefined ,
page : page . value ,
limit : pageSize . value ,
} ;
try {
const { data : res } = await api . get < DeletionArchiveListResponse > ( '/deletions' , { params } ) ;
return res ;
} catch ( e ) {
if ( ! axios . isAxiosError ( e ) || e . response ? . status !== 400 || ! params . deletedById ) throw e ;
const { data : res } = await api . get < DeletionArchiveListResponse > ( '/deletions' , {
params : { q : params . q , page : params . page , limit : params . limit } ,
} ) ;
const items = res . items . filter ( ( item ) => item . deletedById === params . deletedById ) ;
return { ... res , items , total : items . length } ;
}
}
async function loadActors ( ) {
try {
const { data : rows } = await api . get < Array < { id : string ; fullName : string } > > ( '/users' ) ;
actors . value = rows
. filter ( ( row ) => row . fullName )
. sort ( ( a , b ) => a . fullName . localeCompare ( b . fullName , 'fa' ) ) ;
} catch {
actors . value = [ ] ;
}
}
function onPage ( next : number ) {
page . value = next ;
void load ( ) ;
}
function onPageSize ( size : number ) {
pageSize . value = size ;
if ( page . value === 1 ) void load ( ) ;
}
function selectTab ( next : HistoryTab ) {
if ( next === tab . value ) return ;
q . value = '' ;
page . value = 1 ;
data . value = null ;
audit . value = null ;
error . value = '' ;
okMsg . value = '' ;
void router . replace ( { query : next === 'deletes' ? { } : { tab : next } } ) ;
}
async function load ( resetPage = false ) {
if ( resetPage ) page . value = 1 ;
loading . value = true ;
error . value = '' ;
const current = tab . value ;
try {
const { data : res } = await api . get < DeletionArchiveListResponse > ( '/ deletion s' , {
params : { q : q . value . trim ( ) || undefined , page : page . value , limit : 30 } ,
} ) ;
data . value = res ;
retention . value = res . retention ;
if ( current === 'delete s' ) {
const res = await loadDeletes ( ) ;
if ( tab . value !== current ) return ;
audit . value = null ;
data . value = res ;
retention . value = res . retention ;
} else {
const res = await loadAudit ( current ) ;
if ( tab . value !== current ) return ;
data . value = null ;
audit . value = res ;
void hydrate ( res . items ) ;
}
} catch ( e ) {
error . value = apiError ( e , 'بارگذاری لاگ حذف ناموفق بود.' ) ;
if ( tab . value != = current ) return ;
error . value = apiError (
e ,
current === 'deletes' ? 'بارگذاری لاگ حذف ناموفق بود.' : 'بارگذاری تاریخچه ناموفق بود.' ,
) ;
} finally {
loading . value = false ;
if ( tab . value === current ) loading . value = false ;
}
}
@@ -107,27 +450,40 @@ async function confirmPurge() {
}
}
function go ( delta : number ) {
page . value = Math . min ( totalPages . value , Math . max ( 1 , page . value + delta ) ) ;
watch ( tab , ( ) => {
void load ( ) ;
}
} , { immediate : true } ) ;
onMounted ( ( ) => {
void load ( ) ;
watch ( actorId , ( ) => {
void load ( true ) ;
} ) ;
void loadActors ( ) ;
< / script >
< template >
< section class = "page" >
< UiPageHeader
title = "لاگ حذفیات "
lead = "هر حذف این جا میماند. میتوانید برگردانید یا برای همیشه پاک کنی د."
title = "تاریخچه کارها "
lead = "هر ایجاد، ویرایش و حذف اینجا ثبت میشو د."
/ >
< nav class = "tabs" aria -label = " نوع تاریخچه " >
< button
v-for = "item in TABS"
:key = "item.key"
type = "button"
: class = "{ on: tab === item.key }"
@click ="selectTab(item.key)"
>
{ { item . label } }
< / button >
< / nav >
< p v-if = "okMsg" class="ok" > {{ okMsg }} < / p >
< p v-if = "error" class="error" > {{ error }} < / p >
< UiCard >
< UiCard v-if = "tab === 'deletes'" >
< div class = "retention" >
< label for = "deletion-retention" > حذف خودکار از این فهرست < / label >
< select id = "deletion-retention" v-model = "retention" >
@@ -144,18 +500,31 @@ onMounted(() => {
< form class = "search" @submit.prevent ="load(true)" >
< input v-model = "q" type="search" placeholder="جستجو در عنوان یا نوع" / >
< label class = "actor" >
< span > انجام دهنده < / span >
< select v-model = "actorId" >
< option value = "" > همه < / option >
< option v-for = "user in actors" :key="user.id" :value="user.id" > {{ user.fullName }} < / option >
< / select >
< / label >
< UiButton type = "submit" variant = "secondary" size = "sm" > جستجو < / UiButton >
< / form >
< p v-if = "loading" class="muted" > در حال بارگذاری … < / p >
< UiEmptyState
v-else-if = "!error && !data?.items.length"
v-else-if = "tab === 'deletes' && !error && !data?.items.length"
title = "حذفی ثبت نشده."
description = "از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند."
/ >
< UiEmptyState
v-else-if = "tab !== 'deletes' && !error && !audit?.items.length"
: title = "tab === 'creates' ? 'ایجاد جدیدی ثبت نشده.' : 'ویرایشی ثبت نشده.'"
description = "با انجام کار در سامانه، نام کاربر و زمان اینجا دیده میشود. روی مورد بزنید تا خود پرونده باز شود."
/ >
< div v-else-if = "data?.items.length" class="list" >
< article v-for = "item in data.items" :key="item.id" class="row" >
< div v-else-if = "tab === 'deletes' && data?.items.length" class="list" >
< article v-for = "( item, index) in data.items" :key="item.id" class="row" >
< span class = "idx" > { { toFaDigits ( rowNo ( index ) ) } } < / span >
< div class = "info" >
< strong > { { item . label } } < / strong >
< span > { { item . modelLabel } } < / span >
@@ -176,12 +545,42 @@ onMounted(() => {
< / article >
< / div >
< div v-if = "data && data.total > data.limit " class="pager " >
< UiButton size = "sm" variant = "ghost" : disabled = "page <= 1" @click ="go(-1)" > قبلی < / UiButton >
< span > { { toFaDigits ( page ) } } از { { toFaDigits ( totalPages ) } } < / span >
< UiButton size = "sm" variant = "ghost" : disabled = "page >= totalPages" @click ="go(1)" > بعدی < / UiButton >
< div v-else-if = "audit?.items.length " class="list " >
< component
: is = "recordPath(row) ? 'button' : 'div'"
v-for = "(row, index) in audit.items"
:key = "row.id"
class = "row"
: class = "{ link: !!recordPath(row) }"
: type = "recordPath(row) ? 'button' : undefined"
@click ="openRecord(row)"
>
< span class = "idx" > { { toFaDigits ( rowNo ( index ) ) } } < / span >
< div class = "info" >
< strong > { { rowHeading ( row ) } } < / strong >
< span v-if = "rowDetail(row)" class="detail" > {{ rowDetail ( row ) }} < / span >
< span >
< template v-if = "pathPhrase(row) !== rowHeading(row)" > {{ pathPhrase ( row ) }} · < / template >
{{ row.actor ? .fullName | | ' سیستم ' }}
·
{{ formatJalaliDateTime ( new Date ( row.createdAt ) ) }}
< / span >
< / div >
< span v-if = "recordPath(row)" class="open" > مشاهده < / span >
< / component >
< / div >
< UiPagination
v-if = "listSource"
:page = "page"
:total-pages = "totalPages"
:total-items = "listSource.total"
:page-size = "pageSize"
:page-size-options = "PAGE_SIZE_OPTIONS"
@ update :page = "onPage"
@ update :page-size = "onPageSize"
/ >
< UiModal :open = "!!purgeItem" title = "حذف کامل" @close ="purgeId = null" >
< p v-if = "purgeItem" class="confirm" >
« {{ purgeItem.label }} » برای همیشه پاک شود ؟ بعد از این دیگر قابل بازگشت نیست.
@@ -201,6 +600,32 @@ onMounted(() => {
min - width : 0 ;
}
. tabs {
display : flex ;
width : 100 % ;
min - width : 0 ;
overflow - x : auto ;
border - bottom : 1 px solid # e2e8f0 ;
}
. tabs button {
flex : 1 1 0 ;
min - width : 0 ;
border : 0 ;
background : none ;
padding : 0.55 rem 0.2 rem 0.7 rem ;
color : # 64748 b ;
font : inherit ;
font - weight : 700 ;
cursor : pointer ;
box - shadow : inset 0 - 2 px 0 transparent ;
}
. tabs button . on {
color : # 0 f766e ;
box - shadow : inset 0 - 2 px 0 # 14 b8a6 ;
}
. retention {
display : flex ;
flex - wrap : wrap ;
@@ -213,7 +638,8 @@ onMounted(() => {
}
. retention select ,
. search input {
. search input ,
. search select {
border : 1 px solid # e2e8f0 ;
border - radius : 10 px ;
background : # fff ;
@@ -233,9 +659,27 @@ onMounted(() => {
. search {
display : flex ;
flex - wrap : wrap ;
align - items : center ;
gap : 0.5 rem ;
}
. actor {
display : inline - flex ;
align - items : center ;
gap : 0.4 rem ;
font - size : 0.85 rem ;
font - weight : 700 ;
}
. idx {
flex : 0 0 auto ;
min - width : 1.5 rem ;
color : # 94 a3b8 ;
font - size : 0.82 rem ;
font - weight : 700 ;
padding - top : 0.15 rem ;
}
. search input {
flex : 1 1 12 rem ;
}
@@ -247,8 +691,8 @@ onMounted(() => {
. row {
display : flex ;
flex - wrap : wrap ;
align - items : center ;
flex - wrap : nowrap ;
align - items : flex - start ;
justify - content : space - between ;
gap : 0.75 rem ;
padding : 0.8 rem 0.9 rem ;
@@ -256,10 +700,31 @@ onMounted(() => {
border : 1 px solid # e7eef2 ;
border - radius : 14 px ;
min - width : 0 ;
text - align : right ;
}
. row . link {
width : 100 % ;
font : inherit ;
cursor : pointer ;
}
. row . link : hover {
border - color : # 99 f6e4 ;
}
. open {
flex : 0 0 auto ;
margin - inline - start : auto ;
align - self : center ;
color : # 0 f766e ;
font - size : 0.82 rem ;
font - weight : 700 ;
}
. info {
display : grid ;
flex : 1 1 auto ;
gap : 0.15 rem ;
min - width : 0 ;
}
@@ -276,10 +741,20 @@ onMounted(() => {
font - size : 0.82 rem ;
}
. info span . detail {
color : # 334155 ;
white - space : normal ;
overflow : visible ;
text - overflow : unset ;
}
. ops {
display : flex ;
flex : 0 0 auto ;
flex - wrap : wrap ;
gap : 0.4 rem ;
margin - inline - start : auto ;
align - self : center ;
}
. pager {