Files
tehran_taj/apps/admin-panel/src/components/accounting/AccountingConnection.vue
T
mahmoodandCursor 7dd74ff4f7 feat: product design surveys, cart expiry/buy-later, and catalog polish
Add like/dislike surveys with majority alerts for manufacturing decisions, clearer accounting mapping copy, and related cart/catalog admin-app improvements.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:55:52 +03:30

746 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!--
«اتصال» tab of the accounting screen (D27), redesigned 2026-10-06.
One question per step, only what the chosen software needs:
status → 1. which software → 2. how to reach it → 3. its certificate
(only for https, shown as a fact, never as raw PEM) → test and save.
The form object is the page's own (save sends the whole configuration);
this component only edits the connection part of it.
-->
<div class="conn">
<!-- ── status ─────────────────────────────────────────────── -->
<div class="status" :class="`status--${status.tone}`">
<component :is="status.icon" class="status__icon" />
<div class="status__body">
<div class="status__title">
{{ status.title }}
<a-tag v-if="dirty" color="orange" class="status__tag">تغییرات ذخیره نشده</a-tag>
</div>
<div v-if="status.text" class="status__text">{{ status.text }}</div>
<dl v-if="status.rows?.length" class="kv status__rows">
<template v-for="row in status.rows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd :dir="row.ltr ? 'ltr' : undefined" :class="{ latin: row.ltr }">{{ row.value }}</dd>
</template>
</dl>
</div>
</div>
<!-- ── 1. software ────────────────────────────────────────── -->
<section class="step">
<h3 class="step__title"><span class="step__no">۱</span>نرم‌افزار حسابداری</h3>
<div class="choices" role="radiogroup">
<button
v-for="c in CHOICES"
:key="c.value"
type="button"
role="radio"
:aria-checked="form.provider === c.value"
class="choice"
:class="{ 'choice--on': form.provider === c.value }"
@click="form.provider = c.value"
>
<span class="choice__dot" />
<span class="choice__name">
{{ c.label }}
</span>
<span class="choice__desc">{{ c.desc }}</span>
</button>
</div>
<a-alert
v-if="providerChanged"
class="mt"
type="info"
show-icon
message="با ذخیره، نرم‌افزار عوض می‌شود"
description="کارهای در صف نرم‌افزار قبلی متوقف می‌مانند (پاک نمی‌شوند) و لینک‌های هر نرم‌افزار جدا نگه داشته می‌شوند؛ برگشت بی‌خطر است."
/>
</section>
<template v-if="p">
<!-- ── 2. connection details ──────────────────────────────── -->
<section class="step">
<h3 class="step__title"><span class="step__no">۲</span>اطلاعات اتصال به {{ LABEL[p] }}</h3>
<a-form layout="vertical" class="grid">
<a-form-item label="آدرس وب‌سرویس" required>
<a-input v-model:value="form[p].baseUrl" dir="ltr" class="ltr" :placeholder="p === 'tahesab' ? 'https://192.168.1.10:8081' : 'http://192.168.1.10:8000/ZarrinService/accounting'" />
<div class="help">آدرسی که وب‌سرویس {{ LABEL[p] }} روی آن در دسترس است، با پورت.</div>
</a-form-item>
<a-form-item v-if="p === 'tahesab'" label="نام دیتابیس (DBname)" required>
<a-input v-model:value="form.tahesab.dbName" dir="ltr" class="ltr" placeholder="TahesabDB" />
<div class="help">نام دقیق دیتابیس همین شرکت. یک سرور ممکن است چند دیتابیس داشته باشد؛ بعد از تست، نام را در نتیجه چک کنید.</div>
</a-form-item>
<a-form-item v-else label="علامت مانده‌ی بدهکار">
<a-select v-model:value="form.zarrin.balanceOwesWhen">
<a-select-option value="negative">منفی یعنی بدهکار</a-select-option>
<a-select-option value="positive">مثبت یعنی بدهکار</a-select-option>
</a-select>
<div class="help">در زرین منفی یعنی بدهکار (روی نصب واقعی بررسی شد)؛ تغییرش ندهید مگر نصب شما خلاف این باشد.</div>
</a-form-item>
<a-form-item label="توکن وب‌سرویس" required class="grid__full">
<div v-if="tokenSaved && !editingToken[p]" class="token">
<LockOutlined class="token__icon" />
<span v-if="pub(p)?.tokenReadable">توکن ذخیره شده است و نمایش داده نمی‌شود.</span>
<span v-else class="text-danger">توکن ذخیره‌شده قابل‌خواندن نیست؛ دوباره وارد کنید.</span>
<a-space class="token__actions">
<a-button size="small" @click="editingToken[p] = true">تغییر توکن</a-button>
<a-popconfirm title="توکن حذف شود؟ تا توکن جدید وارد نشود اتصال کار نمی‌کند." ok-text="حذف" cancel-text="انصراف" @confirm="emit('clearToken', p)">
<a-button size="small" danger>حذف</a-button>
</a-popconfirm>
</a-space>
</div>
<template v-else>
<a-input-password v-model:value="tokens[p]" dir="ltr" class="ltr" autocomplete="new-password" placeholder="توکن را اینجا وارد کنید" />
<div class="help">
بعد از ذخیره، توکن رمز می‌شود و دیگر نمایش داده نمی‌شود.
<a v-if="tokenSaved" class="link" @click="cancelTokenEdit">انصراف و نگه‌داشتن توکن فعلی</a>
</div>
</template>
</a-form-item>
</a-form>
<a-collapse ghost class="advanced">
<a-collapse-panel key="adv" header="تنظیمات پیشرفته">
<a-form layout="vertical">
<a-form-item label="حداکثر زمان انتظار برای پاسخ">
<a-input-number v-model:value="timeoutSec" :min="3" :max="120" addon-after="ثانیه" />
<div class="help">اگر سرور حسابداری کند است بیشترش کنید. پیش‌فرض ۲۰ ثانیه.</div>
</a-form-item>
</a-form>
</a-collapse-panel>
</a-collapse>
</section>
<!-- ── 3. certificate (https only) ────────────────────────── -->
<section v-if="isHttps" class="step">
<h3 class="step__title"><span class="step__no">۳</span>گواهی امنیتی سرور</h3>
<p class="step__lead">
اتصال همیشه رمزنگاری‌شده و بررسی‌شده است. نصب‌های معمول {{ LABEL[p] }} گواهی خودساخته دارند؛ در این حالت یک بار گواهی سرور را بررسی و تأیید کنید.
</p>
<!-- trusted (pinned) -->
<div v-if="form[p].caCert" class="cert cert--ok">
<SafetyCertificateOutlined class="cert__icon" />
<div class="cert__body">
<div class="cert__title">
گواهی سرور تأیید شده است
<a-tag v-if="certUnsaved" color="orange">ذخیره نشده</a-tag>
<a-tag v-if="certInfo?.expired" color="red">منقضی شده</a-tag>
</div>
<dl v-if="certInfo" class="kv">
<dt>صادرشده برای</dt>
<dd dir="ltr" class="latin">{{ cn(certInfo.subject) }}</dd>
<dt>معتبر تا</dt>
<dd>{{ jdate(certInfo.validTo) }}</dd>
<dt>اثرانگشت</dt>
<dd dir="ltr" class="mono">{{ certInfo.fingerprint256 }}</dd>
</dl>
<div v-else class="help">مشخصات گواهی بعد از ذخیره نمایش داده می‌شود.</div>
</div>
<a-space direction="vertical" class="cert__actions">
<a-button size="small" :loading="checking" @click="checkCert">بررسی دوباره</a-button>
<a-button size="small" danger @click="form[p].caCert = ''">برداشتن</a-button>
</a-space>
</div>
<!-- just checked, waiting for a decision -->
<div v-else-if="checked && !checked.trustedBySystem" class="cert cert--ask">
<ExclamationCircleOutlined class="cert__icon" />
<div class="cert__body">
<div class="cert__title">این گواهی را تأیید می‌کنید؟</div>
<div class="help">اثرانگشت زیر را با گواهی نصب‌شده روی سرور حسابداری مقایسه کنید. اگر یکی است تأیید کنید.</div>
<dl class="kv">
<dt>صادرشده برای</dt>
<dd dir="ltr" class="latin">{{ cn(checked.subject) }}</dd>
<dt>معتبر تا</dt>
<dd>{{ jdate(checked.validTo) }}</dd>
<dt>اثرانگشت</dt>
<dd dir="ltr" class="mono">{{ checked.fingerprint256 }}</dd>
</dl>
<a-alert v-if="checked.expired" type="error" show-icon message="این گواهی منقضی شده است." class="mt" />
</div>
<a-space direction="vertical" class="cert__actions">
<a-button type="primary" size="small" @click="trustChecked">تأیید گواهی</a-button>
<a-button size="small" @click="checked = null">انصراف</a-button>
</a-space>
</div>
<!-- public certificate: nothing to do -->
<div v-else-if="checked?.trustedBySystem" class="cert cert--ok">
<SafetyCertificateOutlined class="cert__icon" />
<div class="cert__body">
<div class="cert__title">گواهی سرور معتبر است</div>
<div class="help">این سرور گواهی معتبر عمومی دارد؛ کاری لازم نیست.</div>
</div>
</div>
<!-- nothing yet -->
<div v-else class="cert">
<SafetyCertificateOutlined class="cert__icon cert__icon--muted" />
<div class="cert__body">
<div class="cert__title">گواهی‌ای تأیید نشده</div>
<div class="help">اگر «تست اتصال» خطای گواهی داد، اینجا گواهی سرور را بررسی کنید.</div>
</div>
<a-button :loading="checking" :disabled="!form[p].baseUrl" @click="checkCert">بررسی گواهی سرور</a-button>
</div>
<a-collapse ghost class="advanced">
<a-collapse-panel key="pem" header="وارد کردن دستی گواهی (PEM)">
<a-textarea v-model:value="form[p].caCert" dir="ltr" class="ltr mono" :rows="5" placeholder="-----BEGIN CERTIFICATE-----" />
</a-collapse-panel>
</a-collapse>
</section>
</template>
<!-- ── test result ────────────────────────────────────────── -->
<section v-if="p && result" class="step">
<a-alert v-if="result.ok" type="success" show-icon :message="`اتصال به ${LABEL[p]} برقرار است`">
<template #description>
<dl class="kv kv--inline">
<template v-for="row in resultRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd :dir="row.ltr ? 'ltr' : undefined" :class="{ latin: row.ltr }">{{ row.value }}</dd>
</template>
</dl>
<div v-if="dbMismatch" class="text-danger mt">
دیتابیسی که وصل شد <bdi class="latin">{{ result.info.dbName }}</bdi> است، نه <bdi class="latin">{{ form.tahesab.dbName }}</bdi>. نام دیتابیس را چک کنید.
</div>
<div v-if="resultStale" class="help">این نتیجه برای مقادیر قبلی است؛ دوباره تست کنید.</div>
</template>
</a-alert>
<a-alert v-else type="error" show-icon message="اتصال برقرار نشد">
<template #description>
<div>{{ result.error }}</div>
<div v-if="resultStale" class="help">این نتیجه برای مقادیر قبلی است؛ دوباره تست کنید.</div>
</template>
</a-alert>
</section>
<!-- ── actions ────────────────────────────────────────────── -->
<div class="bar">
<span class="bar__note">
<template v-if="dirty">تغییرات هنوز ذخیره نشده‌اند.</template>
<template v-else-if="p">«تست اتصال» مقادیر همین فرم را امتحان می‌کند، حتی پیش از ذخیره.</template>
</span>
<a-space>
<a-button v-if="p" :loading="testing" :disabled="!canTest" @click="test">تست اتصال</a-button>
<a-button type="primary" :loading="saving" :disabled="!dirty" @click="emit('save')">ذخیره</a-button>
</a-space>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import {
CheckCircleFilled,
CloseCircleFilled,
ExclamationCircleOutlined,
LockOutlined,
MinusCircleFilled,
SafetyCertificateOutlined,
} from '@ant-design/icons-vue';
import moment from 'moment-jalaali';
import api from '../../api/client';
type ProviderName = 'tahesab' | 'zarrin';
const props = defineProps<{
overview: any;
form: any;
tokens: Record<ProviderName, string>;
saving: boolean;
}>();
const emit = defineEmits<{ (e: 'save'): void; (e: 'clearToken', p: ProviderName): void }>();
const LABEL: Record<ProviderName, string> = { tahesab: 'ته‌حساب', zarrin: 'زرین' };
const CHOICES: { value: 'none' | ProviderName; label: string; desc: string }[] = [
{ value: 'none', label: 'بدون اتصال', desc: 'هیچ اطلاعاتی با حسابداری رد و بدل نمی‌شود.' },
{ value: 'tahesab', label: 'ته‌حساب', desc: 'اتصال به وب‌سرویس نرم‌افزار ته‌حساب.' },
{ value: 'zarrin', label: 'زرین', desc: 'اتصال به وب‌سرویس نرم‌افزار زرین.' },
];
const p = computed<ProviderName | null>(() => (props.form.provider === 'none' ? null : props.form.provider));
const saved = computed(() => props.overview?.config ?? null);
const pub = (name: ProviderName) => saved.value?.[name];
const tokenSaved = computed(() => !!(p.value && pub(p.value)?.tokenSet));
const isHttps = computed(() => !!p.value && /^https:\/\//i.test(props.form[p.value].baseUrl || ''));
// ── dirty: the connection part of the form against what is saved ──
const snapshot = (src: any, provider: string) =>
JSON.stringify(
provider === 'none'
? { provider }
: {
provider,
baseUrl: src[provider]?.baseUrl ?? '',
caCert: src[provider]?.caCert ?? '',
timeoutMs: src[provider]?.timeoutMs,
dbName: provider === 'tahesab' ? src.tahesab?.dbName : undefined,
balanceOwesWhen: provider === 'zarrin' ? src.zarrin?.balanceOwesWhen : undefined,
},
);
const dirty = computed(() => {
if (!saved.value) return false;
if (props.form.provider !== saved.value.provider) return true;
if (!p.value) return false;
return snapshot(props.form, p.value) !== snapshot(saved.value, p.value) || !!props.tokens[p.value]?.trim();
});
const providerChanged = computed(() => !!saved.value && saved.value.provider !== 'none' && saved.value.provider !== props.form.provider);
// ── status card ──
interface StatusRow {
label: string;
value: string;
ltr?: boolean;
}
const jdt = (v?: string | null) => (v ? moment(v).format('jYYYY/jMM/jDD ساعت HH:mm') : '');
const status = computed(() => {
const ov = props.overview;
if (!ov) return { tone: 'off', icon: MinusCircleFilled, title: '…', text: '', rows: [] as StatusRow[] };
const c = ov.config;
if (c.provider === 'none') {
return { tone: 'off', icon: MinusCircleFilled, title: 'اتصال خاموش است', text: 'هیچ اطلاعاتی با نرم‌افزار حسابداری رد و بدل نمی‌شود.' };
}
const label = LABEL[c.provider as ProviderName];
if (ov.inactiveReason) {
return { tone: 'bad', icon: CloseCircleFilled, title: `اتصال به ${label} کار نمی‌کند`, text: ov.inactiveReason };
}
const rt = ov.realtime;
if (rt?.lastError) {
return { tone: 'bad', icon: CloseCircleFilled, title: `${label}: ارتباط برقرار نیست`, text: rt.lastError };
}
const conn = c[c.provider];
const rows: StatusRow[] = [{ label: 'آدرس', value: conn.baseUrl, ltr: true }];
if (c.provider === 'tahesab') rows.push({ label: 'دیتابیس', value: c.tahesab.dbName, ltr: true });
if (rt?.lastOkAt) rows.push({ label: 'آخرین ارتباط موفق', value: jdt(rt.lastOkAt) });
return { tone: 'ok', icon: CheckCircleFilled, title: `متصل به ${label}`, text: '', rows };
});
// ── token ──
const editingToken = reactive<Record<ProviderName, boolean>>({ tahesab: false, zarrin: false });
function cancelTokenEdit() {
if (!p.value) return;
props.tokens[p.value] = '';
editingToken[p.value] = false;
}
watch(
() => props.overview,
() => {
editingToken.tahesab = false;
editingToken.zarrin = false;
checked.value = null;
},
);
// ── timeout shown in seconds ──
const timeoutSec = computed({
get: () => (p.value ? Math.round((props.form[p.value].timeoutMs ?? 20000) / 1000) : 20),
set: (v: number) => {
if (p.value) props.form[p.value].timeoutMs = Math.max(3, Math.min(120, Number(v) || 20)) * 1000;
},
});
// ── certificate ──
const checking = ref(false);
const checked = ref<any>(null);
const cn = (subject: string) => (subject.match(/CN=([^\n,]+)/)?.[1] ?? subject.replace(/\n/g, ', '));
const jdate = (v: string) => (v ? moment(new Date(v)).format('jYYYY/jMM/jDD') : '—');
const certUnsaved = computed(() => !!p.value && (props.form[p.value].caCert || '') !== (pub(p.value)?.caCert || ''));
const certInfo = computed(() => {
if (!p.value) return null;
if (!certUnsaved.value) return pub(p.value)?.caInfo ?? null;
return checked.value && checked.value.pem === props.form[p.value].caCert ? checked.value : null;
});
async function checkCert() {
if (!p.value) return;
checking.value = true;
try {
const { data } = await api.post('admin/accounting/certificate', { baseUrl: props.form[p.value].baseUrl });
checked.value = data;
if (props.form[p.value].caCert && data.pem.trim() === props.form[p.value].caCert.trim()) {
message.success('گواهی سرور همان گواهی تأییدشده است');
} else if (props.form[p.value].caCert) {
// A different certificate than the trusted one: ask again.
props.form[p.value].caCert = '';
}
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'گواهی دریافت نشد');
} finally {
checking.value = false;
}
}
function trustChecked() {
if (!p.value || !checked.value) return;
props.form[p.value].caCert = checked.value.pem;
}
// ── test ──
const testing = ref(false);
const result = ref<any>(null);
const testedWith = ref('');
const fingerprint = () => (p.value ? JSON.stringify([props.form.provider, props.form[p.value], props.tokens[p.value]]) : '');
const resultStale = computed(() => !!result.value && testedWith.value !== fingerprint());
const canTest = computed(() => !!p.value && !!props.form[p.value].baseUrl && (tokenSaved.value || !!props.tokens[p.value]?.trim()));
watch(p, () => (result.value = null));
async function test() {
if (!p.value) return;
const name = p.value;
testing.value = true;
try {
const connection: Record<string, unknown> = {
baseUrl: props.form[name].baseUrl,
timeoutMs: props.form[name].timeoutMs,
caCert: props.form[name].caCert,
token: props.tokens[name] || undefined,
};
if (name === 'tahesab') connection.dbName = props.form.tahesab.dbName;
else connection.balanceOwesWhen = props.form.zarrin.balanceOwesWhen;
testedWith.value = fingerprint();
const { data } = await api.post('admin/accounting/test', { provider: name, connection });
result.value = data;
} catch (e: any) {
const m = e?.response?.data?.message;
result.value = { ok: false, error: Array.isArray(m) ? m.join('، ') : (m ?? 'خطا در تست اتصال') };
} finally {
testing.value = false;
}
}
const INFO_LABELS: Record<string, { label: string; ltr?: boolean }> = {
version: { label: 'نسخه', ltr: true },
dbType: { label: 'نوع دیتابیس', ltr: true },
dbName: { label: 'دیتابیس', ltr: true },
customerGroups: { label: 'گروه‌های حساب' },
message: { label: 'پیام سرور' },
};
const resultRows = computed(() => {
const info = result.value?.info ?? {};
const rows = Object.entries(INFO_LABELS)
.filter(([k]) => info[k] !== undefined && info[k] !== null && info[k] !== '')
.map(([k, v]) => ({ label: v.label, value: String(info[k]), ltr: v.ltr }));
rows.push({ label: 'زمان پاسخ', value: `${(result.value?.ms ?? 0).toLocaleString('fa-IR')} میلی‌ثانیه`, ltr: false });
return rows;
});
const dbMismatch = computed(
() =>
p.value === 'tahesab' &&
!!result.value?.info?.dbName &&
String(result.value.info.dbName).toLowerCase() !== String(props.form.tahesab.dbName || '').toLowerCase(),
);
</script>
<style scoped>
.conn {
max-width: 920px;
}
.mt {
margin-top: 12px;
}
.help {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
margin-top: 4px;
line-height: 1.8;
}
.link {
margin-inline-start: 8px;
cursor: pointer;
}
.text-danger {
color: #cf1322;
}
.ltr {
direction: ltr;
text-align: left;
}
.ltr :deep(input),
.ltr :deep(textarea) {
direction: ltr;
text-align: left;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
/* status */
.status {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 16px 18px;
border-radius: 10px;
border: 1px solid;
margin-bottom: 24px;
}
.status__icon {
font-size: 22px;
margin-top: 2px;
}
.status__title {
font-weight: 700;
font-size: 15px;
}
.status__tag {
margin-inline-start: 8px;
font-weight: 400;
}
.status__text {
font-size: 13px;
margin-top: 4px;
color: rgba(0, 0, 0, 0.65);
overflow-wrap: anywhere;
}
.status__rows {
margin-top: 6px;
}
/* URLs, database names, fingerprints: Latin digits, left-to-right */
.latin,
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
unicode-bidi: isolate;
}
.status--ok {
background: #e6f4ff;
border-color: #91caff;
}
.status--ok .status__icon {
color: #1677ff;
}
.status--ok .status__title {
color: #0958d9;
}
.status--bad {
background: #fff2f0;
border-color: #ffccc7;
}
.status--bad .status__icon {
color: #ff4d4f;
}
.status--off {
background: #fafafa;
border-color: #f0f0f0;
}
.status--off .status__icon {
color: #bfbfbf;
}
/* steps */
.step {
padding: 4px 0 24px;
margin-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
}
.step__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 700;
margin: 0 0 14px;
}
.step__no {
display: inline-grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: #1f2a5c;
color: #fff;
font-size: 13px;
}
.step__lead {
margin: -4px 0 14px;
color: rgba(0, 0, 0, 0.6);
font-size: 13px;
line-height: 1.9;
}
/* provider choice */
.choices {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.choice {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 10px;
text-align: start;
padding: 14px 16px;
border: 1px solid #d9d9d9;
border-radius: 10px;
background: #fff;
cursor: pointer;
font: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.choice:hover {
border-color: #8c9bd1;
}
.choice:focus-visible {
outline: 2px solid #1f2a5c;
outline-offset: 2px;
}
.choice--on {
border-color: #1f2a5c;
box-shadow: 0 0 0 1px #1f2a5c;
background: #f5f7ff;
}
.choice__dot {
grid-row: span 2;
width: 16px;
height: 16px;
margin-top: 3px;
border-radius: 50%;
border: 2px solid #bfbfbf;
}
.choice--on .choice__dot {
border: 5px solid #1f2a5c;
}
.choice__name {
font-weight: 700;
}
.choice__tag {
margin-inline-start: 6px;
font-weight: 400;
}
.choice__desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
}
/* form grid */
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
}
.grid__full {
grid-column: 1 / -1;
}
.advanced {
margin-top: 4px;
}
.advanced :deep(.ant-collapse-header) {
padding-inline: 0 !important;
color: rgba(0, 0, 0, 0.6);
}
/* token */
.token {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
.token__icon {
color: #52c41a;
}
.token__actions {
margin-inline-start: auto;
}
/* certificate */
.cert {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 16px;
border: 1px solid #f0f0f0;
border-radius: 10px;
background: #fafafa;
}
.cert--ok {
background: #f6ffed;
border-color: #d9f7be;
}
.cert--ask {
background: #fffbe6;
border-color: #ffe58f;
}
.cert__icon {
font-size: 22px;
color: #52c41a;
margin-top: 2px;
}
.cert--ask .cert__icon {
color: #faad14;
}
.cert__icon--muted {
color: #bfbfbf;
}
.cert__body {
flex: 1;
min-width: 0;
}
.cert__title {
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.cert__actions {
flex-shrink: 0;
}
/* key/value lists */
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 14px;
margin: 8px 0 0;
font-size: 12.5px;
}
.kv dt {
color: rgba(0, 0, 0, 0.5);
}
.kv dd {
margin: 0;
text-align: start;
}
.kv dd[dir='ltr'] {
text-align: right;
}
/* action bar */
.bar {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.bar__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
}
@media (max-width: 760px) {
.choices,
.grid {
grid-template-columns: 1fr;
}
.cert {
flex-wrap: wrap;
}
}
</style>