Add profile generate refine via manager hint and auto-preview.
When Preview leaves fields empty, managers can send a hint plus the current profile so DeepSeek revises rules; generate now returns preview and empty_fields for the UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,10 +20,14 @@ router = APIRouter(prefix="/admin/parser-profiles", tags=["parser-profiles"])
|
|||||||
|
|
||||||
class GenerateRequest(BaseModel):
|
class GenerateRequest(BaseModel):
|
||||||
sample_post: str = Field(min_length=1)
|
sample_post: str = Field(min_length=1)
|
||||||
|
hint: str | None = None
|
||||||
|
current_profile: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class GenerateResponse(BaseModel):
|
class GenerateResponse(BaseModel):
|
||||||
profile: dict[str, Any]
|
profile: dict[str, Any]
|
||||||
|
preview: dict[str, str]
|
||||||
|
empty_fields: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PreviewRequest(BaseModel):
|
class PreviewRequest(BaseModel):
|
||||||
@@ -65,7 +69,11 @@ async def generate_profile(payload: GenerateRequest):
|
|||||||
detail="DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation.",
|
detail="DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation.",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
profile = await builder.generate_profile(payload.sample_post)
|
profile = await builder.generate_profile(
|
||||||
|
payload.sample_post,
|
||||||
|
hint=payload.hint,
|
||||||
|
current_profile=payload.current_profile,
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -73,7 +81,13 @@ async def generate_profile(payload: GenerateRequest):
|
|||||||
status_code=502,
|
status_code=502,
|
||||||
detail=f"DeepSeek generate failed: {exc}",
|
detail=f"DeepSeek generate failed: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
return GenerateResponse(profile=profile.model_dump())
|
dumped = profile.model_dump()
|
||||||
|
preview = builder.preview_with_profile(payload.sample_post, dumped)
|
||||||
|
return GenerateResponse(
|
||||||
|
profile=dumped,
|
||||||
|
preview=preview,
|
||||||
|
empty_fields=builder.empty_preview_fields(preview),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/preview", response_model=PreviewResponse)
|
@router.post("/preview", response_model=PreviewResponse)
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ logger = logging.getLogger("ca.parser_builder")
|
|||||||
GENERATE_SYSTEM = (
|
GENERATE_SYSTEM = (
|
||||||
"You design a static heuristic parser profile for structured Telegram posts. "
|
"You design a static heuristic parser profile for structured Telegram posts. "
|
||||||
"Output valid JSON only, no markdown, no Python code. "
|
"Output valid JSON only, no markdown, no Python code. "
|
||||||
"The profile is applied with regex/line/marker rules at runtime — never with an LLM."
|
"The profile is applied with regex/line/marker rules at runtime — never with an LLM. "
|
||||||
|
"Every rule must actually extract a non-empty value from the given sample when applied."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +47,10 @@ def preview_with_profile(sample_post: str, profile: dict[str, Any] | HeuristicPr
|
|||||||
return apply_profile(sample_post, profile)
|
return apply_profile(sample_post, profile)
|
||||||
|
|
||||||
|
|
||||||
|
def empty_preview_fields(preview: dict[str, str]) -> list[str]:
|
||||||
|
return [name for name, value in preview.items() if not (value or "").strip()]
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_object(content: str) -> dict[str, Any]:
|
def _extract_json_object(content: str) -> dict[str, Any]:
|
||||||
content = (content or "").strip()
|
content = (content or "").strip()
|
||||||
if content.startswith("```"):
|
if content.startswith("```"):
|
||||||
@@ -63,21 +68,11 @@ def _extract_json_object(content: str) -> dict[str, Any]:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
async def generate_profile(sample_post: str) -> HeuristicProfile:
|
def _base_user_prompt(sample: str) -> str:
|
||||||
settings = deepseek_settings()
|
|
||||||
if not settings["api_key"]:
|
|
||||||
raise RuntimeError(
|
|
||||||
"DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation."
|
|
||||||
)
|
|
||||||
|
|
||||||
sample = (sample_post or "").strip()
|
|
||||||
if not sample:
|
|
||||||
raise ValueError("sample_post is required")
|
|
||||||
|
|
||||||
fields_help = "\n".join(
|
fields_help = "\n".join(
|
||||||
f"- {spec['name']}: {spec['description']}" for spec in target_field_specs()
|
f"- {spec['name']}: {spec['description']}" for spec in target_field_specs()
|
||||||
)
|
)
|
||||||
user_prompt = (
|
return (
|
||||||
"Build a HeuristicProfile JSON for this sample Telegram post.\n\n"
|
"Build a HeuristicProfile JSON for this sample Telegram post.\n\n"
|
||||||
"Schema:\n"
|
"Schema:\n"
|
||||||
'{"version": 1, "notes": "...", "fields": {'
|
'{"version": 1, "notes": "...", "fields": {'
|
||||||
@@ -89,14 +84,88 @@ async def generate_profile(sample_post: str) -> HeuristicProfile:
|
|||||||
"- Only include fields you can extract reliably from the sample.\n"
|
"- Only include fields you can extract reliably from the sample.\n"
|
||||||
f"- Allowed field names: {', '.join(TARGET_FIELDS)}.\n"
|
f"- Allowed field names: {', '.join(TARGET_FIELDS)}.\n"
|
||||||
"- Prefer regex / line / after_marker / between over literal.\n"
|
"- Prefer regex / line / after_marker / between over literal.\n"
|
||||||
"- Use literal only for constant topic tags.\n"
|
"- Use literal only for constant topic/region tags.\n"
|
||||||
|
"- title: usually the first non-empty line (strategy line, line_index 0).\n"
|
||||||
|
"- description: the multi-line body AFTER the title and BEFORE coords/hashtags. "
|
||||||
|
"Prefer strategy between or regex with flags including \"s\" (DOTALL). "
|
||||||
|
"Do NOT leave description empty if the sample has a body paragraph.\n"
|
||||||
|
"- locality: settlement name from the title line when present.\n"
|
||||||
"- coords should capture 'lat, lon' when present.\n"
|
"- coords should capture 'lat, lon' when present.\n"
|
||||||
"- event_date should capture DD.MM.YYYY or similar.\n"
|
"- event_date should capture DD.MM.YYYY / DD.MM.YY or similar.\n"
|
||||||
"- Do not invent Python code; only declarative rules.\n\n"
|
"- topic/region: hashtags like #ru → topic \"ru\"; avoid stuffing unrelated tags into region.\n"
|
||||||
|
"- Do not invent Python code; only declarative rules.\n"
|
||||||
|
"- Mentally apply each rule to the sample: every included field must yield a non-empty string.\n\n"
|
||||||
f"Target fields:\n{fields_help}\n\n"
|
f"Target fields:\n{fields_help}\n\n"
|
||||||
f"Sample post:\n{sample[:12000]}"
|
f"Sample post:\n{sample[:12000]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_profile_response(content: str) -> HeuristicProfile:
|
||||||
|
raw = _extract_json_object(content)
|
||||||
|
if "fields" not in raw and isinstance(raw.get("profile"), dict):
|
||||||
|
raw = raw["profile"]
|
||||||
|
if "version" not in raw:
|
||||||
|
raw["version"] = 1
|
||||||
|
return HeuristicProfile.model_validate(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_profile(
|
||||||
|
sample_post: str,
|
||||||
|
*,
|
||||||
|
hint: str | None = None,
|
||||||
|
current_profile: dict[str, Any] | HeuristicProfile | None = None,
|
||||||
|
) -> HeuristicProfile:
|
||||||
|
settings = deepseek_settings()
|
||||||
|
if not settings["api_key"]:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation."
|
||||||
|
)
|
||||||
|
|
||||||
|
sample = (sample_post or "").strip()
|
||||||
|
if not sample:
|
||||||
|
raise ValueError("sample_post is required")
|
||||||
|
|
||||||
|
hint_text = (hint or "").strip()
|
||||||
|
current: HeuristicProfile | None = None
|
||||||
|
if current_profile is not None:
|
||||||
|
current = (
|
||||||
|
current_profile
|
||||||
|
if isinstance(current_profile, HeuristicProfile)
|
||||||
|
else HeuristicProfile.model_validate(current_profile)
|
||||||
|
)
|
||||||
|
|
||||||
|
if current is not None or hint_text:
|
||||||
|
preview = preview_with_profile(sample, current) if current is not None else {}
|
||||||
|
empty = empty_preview_fields(preview) if preview else []
|
||||||
|
parts = [
|
||||||
|
"Revise the HeuristicProfile JSON for this sample Telegram post.",
|
||||||
|
"Keep strategies that already work; fix only what the manager asks for "
|
||||||
|
"and any fields that preview as empty.",
|
||||||
|
"Output the full updated profile JSON (same schema), not a patch.",
|
||||||
|
"",
|
||||||
|
_base_user_prompt(sample),
|
||||||
|
]
|
||||||
|
if current is not None:
|
||||||
|
parts.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"Current profile JSON:",
|
||||||
|
json.dumps(current.model_dump(), ensure_ascii=False, indent=2)[:12000],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if empty:
|
||||||
|
parts.append(
|
||||||
|
"Fields currently empty in preview (must become non-empty if present in sample): "
|
||||||
|
+ ", ".join(empty)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parts.append("Current preview fields: " + json.dumps(preview, ensure_ascii=False))
|
||||||
|
if hint_text:
|
||||||
|
parts.extend(["", "Manager hint (follow this):", hint_text[:4000]])
|
||||||
|
user_prompt = "\n".join(parts)
|
||||||
|
else:
|
||||||
|
user_prompt = _base_user_prompt(sample)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": settings["model"],
|
"model": settings["model"],
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -121,10 +190,4 @@ async def generate_profile(sample_post: str) -> HeuristicProfile:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
content = data["choices"][0]["message"]["content"]
|
content = data["choices"][0]["message"]["content"]
|
||||||
raw = _extract_json_object(content)
|
return _parse_profile_response(content)
|
||||||
# Accept either top-level profile or {"profile": {...}}
|
|
||||||
if "fields" not in raw and isinstance(raw.get("profile"), dict):
|
|
||||||
raw = raw["profile"]
|
|
||||||
if "version" not in raw:
|
|
||||||
raw["version"] = 1
|
|
||||||
return HeuristicProfile.model_validate(raw)
|
|
||||||
|
|||||||
@@ -64,10 +64,29 @@ export function fetchTargetFields(): Promise<{ fields: TargetField[] }> {
|
|||||||
return request<{ fields: TargetField[] }>("/parser-profiles/target-fields", undefined, ADMIN_BASE);
|
return request<{ fields: TargetField[] }>("/parser-profiles/target-fields", undefined, ADMIN_BASE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateParserProfile(sample_post: string): Promise<{ profile: HeuristicProfile }> {
|
export function generateParserProfile(payload: {
|
||||||
return request<{ profile: HeuristicProfile }>(
|
sample_post: string;
|
||||||
|
hint?: string;
|
||||||
|
current_profile?: HeuristicProfile | Record<string, unknown> | null;
|
||||||
|
}): Promise<{
|
||||||
|
profile: HeuristicProfile;
|
||||||
|
preview: Record<string, string>;
|
||||||
|
empty_fields: string[];
|
||||||
|
}> {
|
||||||
|
return request<{
|
||||||
|
profile: HeuristicProfile;
|
||||||
|
preview: Record<string, string>;
|
||||||
|
empty_fields: string[];
|
||||||
|
}>(
|
||||||
"/parser-profiles/generate",
|
"/parser-profiles/generate",
|
||||||
{ method: "POST", body: JSON.stringify({ sample_post }) },
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
sample_post: payload.sample_post,
|
||||||
|
hint: payload.hint || undefined,
|
||||||
|
current_profile: payload.current_profile || undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
ADMIN_BASE,
|
ADMIN_BASE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const success = ref("");
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
|
||||||
const samplePost = ref("");
|
const samplePost = ref("");
|
||||||
|
const generateHint = ref("");
|
||||||
const profileName = ref("");
|
const profileName = ref("");
|
||||||
const targetFields = ref<TargetField[]>([]);
|
const targetFields = ref<TargetField[]>([]);
|
||||||
const profileJson = ref("");
|
const profileJson = ref("");
|
||||||
const previewFields = ref<Record<string, string> | null>(null);
|
const previewFields = ref<Record<string, string> | null>(null);
|
||||||
|
const emptyFields = ref<string[]>([]);
|
||||||
const generating = ref(false);
|
const generating = ref(false);
|
||||||
const previewing = ref(false);
|
const previewing = ref(false);
|
||||||
const loadingFields = ref(true);
|
const loadingFields = ref(true);
|
||||||
@@ -73,10 +75,32 @@ async function handleGenerate() {
|
|||||||
error.value = "";
|
error.value = "";
|
||||||
success.value = "";
|
success.value = "";
|
||||||
previewFields.value = null;
|
previewFields.value = null;
|
||||||
|
emptyFields.value = [];
|
||||||
try {
|
try {
|
||||||
const data = await generateParserProfile(samplePost.value);
|
let current: HeuristicProfile | null = null;
|
||||||
|
if (profileJson.value.trim()) {
|
||||||
|
current = syncProfileFromJson();
|
||||||
|
if (!current) return;
|
||||||
|
}
|
||||||
|
const data = await generateParserProfile({
|
||||||
|
sample_post: samplePost.value,
|
||||||
|
hint: generateHint.value.trim() || undefined,
|
||||||
|
current_profile: current,
|
||||||
|
});
|
||||||
profileJson.value = JSON.stringify(data.profile, null, 2);
|
profileJson.value = JSON.stringify(data.profile, null, 2);
|
||||||
success.value = "Профиль сгенерирован. Preview и runtime — только по правилам, без LLM.";
|
previewFields.value = data.preview;
|
||||||
|
emptyFields.value = data.empty_fields || [];
|
||||||
|
if (emptyFields.value.length) {
|
||||||
|
success.value =
|
||||||
|
`Профиль обновлён, но пустые поля: ${emptyFields.value.join(", ")}. ` +
|
||||||
|
"Допишите подсказку ниже и снова нажмите Generate.";
|
||||||
|
if (!generateHint.value.trim() && emptyFields.value.includes("description")) {
|
||||||
|
generateHint.value =
|
||||||
|
"description должен содержать многострочный текст тела поста после заголовка и до координат (47.…, 36.…), без строки заголовка и без хвоста Источник/Геопривязка/#теги";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
success.value = "Профиль сгенерирован. Preview и runtime — только по правилам, без LLM.";
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "Ошибка генерации";
|
error.value = err instanceof Error ? err.message : "Ошибка генерации";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -99,6 +123,9 @@ async function handlePreview() {
|
|||||||
try {
|
try {
|
||||||
const data = await previewParserProfile(samplePost.value, current);
|
const data = await previewParserProfile(samplePost.value, current);
|
||||||
previewFields.value = data.fields;
|
previewFields.value = data.fields;
|
||||||
|
emptyFields.value = Object.entries(data.fields)
|
||||||
|
.filter(([, v]) => !v)
|
||||||
|
.map(([k]) => k);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "Ошибка preview";
|
error.value = err instanceof Error ? err.message : "Ошибка preview";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,9 +137,11 @@ function resetEditor() {
|
|||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
profileName.value = "";
|
profileName.value = "";
|
||||||
samplePost.value = "";
|
samplePost.value = "";
|
||||||
|
generateHint.value = "";
|
||||||
profileJson.value = "";
|
profileJson.value = "";
|
||||||
profileStatus.value = "draft";
|
profileStatus.value = "draft";
|
||||||
previewFields.value = null;
|
previewFields.value = null;
|
||||||
|
emptyFields.value = [];
|
||||||
success.value = "";
|
success.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,11 +149,13 @@ function openEdit(p: ParserProfile) {
|
|||||||
editingId.value = p.id;
|
editingId.value = p.id;
|
||||||
profileName.value = p.name;
|
profileName.value = p.name;
|
||||||
samplePost.value = p.sample_post || "";
|
samplePost.value = p.sample_post || "";
|
||||||
|
generateHint.value = "";
|
||||||
profileJson.value = p.heuristic_profile
|
profileJson.value = p.heuristic_profile
|
||||||
? JSON.stringify(p.heuristic_profile, null, 2)
|
? JSON.stringify(p.heuristic_profile, null, 2)
|
||||||
: "";
|
: "";
|
||||||
profileStatus.value = p.status === "ready" ? "ready" : "draft";
|
profileStatus.value = p.status === "ready" ? "ready" : "draft";
|
||||||
previewFields.value = null;
|
previewFields.value = null;
|
||||||
|
emptyFields.value = [];
|
||||||
success.value = "";
|
success.value = "";
|
||||||
error.value = "";
|
error.value = "";
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
@@ -197,8 +228,9 @@ onMounted(() => {
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<h2 class="page-heading">Профили парсера</h2>
|
<h2 class="page-heading">Профили парсера</h2>
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Статичный эвристический профиль: Generate (DeepSeek один раз) → Preview → Сохранить.
|
Generate строит статичные правила по образцу поста. Если Preview пустой по полю —
|
||||||
Дальше профиль подключается к каналу на странице
|
напишите подсказку и снова Generate (агент правит текущий JSON). Можно править JSON
|
||||||
|
вручную. Дальше профиль подключается к каналу на
|
||||||
<router-link to="/parsers">Парсеры</router-link>.
|
<router-link to="/parsers">Парсеры</router-link>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -231,6 +263,16 @@ onMounted(() => {
|
|||||||
placeholder="15.03.2024 Населённый пункт Описание… 48.123456, 37.654321"
|
placeholder="15.03.2024 Населённый пункт Описание… 48.123456, 37.654321"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Подсказка агенту
|
||||||
|
<span class="muted"> (если Generate ошибся — опишите, что исправить, и нажмите Generate снова)</span>
|
||||||
|
<textarea
|
||||||
|
v-model="generateHint"
|
||||||
|
class="hint-area"
|
||||||
|
rows="3"
|
||||||
|
placeholder="например: description — абзацы между заголовком и координатами, без #хештегов"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -238,7 +280,13 @@ onMounted(() => {
|
|||||||
:disabled="generating || !samplePost.trim()"
|
:disabled="generating || !samplePost.trim()"
|
||||||
@click="handleGenerate"
|
@click="handleGenerate"
|
||||||
>
|
>
|
||||||
{{ generating ? "Генерация..." : "Generate" }}
|
{{
|
||||||
|
generating
|
||||||
|
? "Генерация..."
|
||||||
|
: generateHint.trim() || profileJson.trim()
|
||||||
|
? "Generate / Refine"
|
||||||
|
: "Generate"
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -297,6 +345,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<section v-if="previewFields" class="card">
|
<section v-if="previewFields" class="card">
|
||||||
<h3>Preview строки</h3>
|
<h3>Preview строки</h3>
|
||||||
|
<p v-if="emptyFields.length" class="form-warn">
|
||||||
|
Пустые поля: <code>{{ emptyFields.join(", ") }}</code> — уточните подсказку и Generate / Refine.
|
||||||
|
</p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -306,7 +357,12 @@ onMounted(() => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td v-for="(val, key) in previewFields" :key="key" class="preview-cell">
|
<td
|
||||||
|
v-for="(val, key) in previewFields"
|
||||||
|
:key="key"
|
||||||
|
class="preview-cell"
|
||||||
|
:class="{ empty: !val }"
|
||||||
|
>
|
||||||
{{ val || "—" }}
|
{{ val || "—" }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -376,7 +432,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sample-area,
|
.sample-area,
|
||||||
.profile-area {
|
.profile-area,
|
||||||
|
.hint-area {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
@@ -389,6 +446,10 @@ onMounted(() => {
|
|||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint-area {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-2 {
|
.grid-2 {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -408,12 +469,23 @@ onMounted(() => {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-cell.empty {
|
||||||
|
background: #fff8e1;
|
||||||
|
color: #b26a00;
|
||||||
|
}
|
||||||
|
|
||||||
.form-success {
|
.form-success {
|
||||||
color: #2e7d32;
|
color: #2e7d32;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-warn {
|
||||||
|
color: #b26a00;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user