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>
746 lines
26 KiB
Vue
746 lines
26 KiB
Vue
<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>
|