1304 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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.

<script setup lang="ts">
import {
AlertTriangle,
BookOpenCheck,
Check,
ChevronLeft,
ChevronRight,
ExternalLink,
FileSearch,
LoaderCircle,
RefreshCw,
Search,
X,
} from "@lucide/vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth.store";
import {
addStandard,
editStandard,
getStandard,
getStandards,
} from "@/services/standards.service";
import type {
StandardCategory,
StandardRuleCreate,
StandardRuleDetail,
StandardRuleListData,
} from "@/types/standards";
const categories: Array<{ key: string; label: string }> = [
{ key: "all", label: "全部" },
{ key: "内料", label: "内料" },
{ key: "包材", label: "包材" },
{ key: "包装", label: "包装" },
{ key: "法规", label: "法规" },
];
const categoryMeta: Record<string, { tone: string; description: string }> = {
内料: { tone: "material", description: "感官理化、配方稳定性与原料合规" },
包材: { tone: "packaging", description: "注塑、吹塑、玻璃、软管及标签验收" },
包装: { tone: "package", description: "标签标识、宣称、版面与结构功能" },
法规: { tone: "law", description: "产品质量、广告与化妆品法规要求" },
};
const keyword = ref("");
const route = useRoute();
const auth = useAuthStore();
const category = ref("all");
const status = ref("启用");
const page = ref(1);
const data = ref<StandardRuleListData | null>(null);
const detail = ref<StandardRuleDetail | null>(null);
const loading = ref(false);
const detailLoading = ref(false);
const error = ref("");
const detailError = ref("");
const editing = ref(false);
const saving = ref(false);
const saveError = ref("");
const form = ref<StandardRuleCreate>(emptyForm());
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function emptyForm(): StandardRuleCreate {
return {
categoryLv1: "内料",
categoryLv2: "",
categoryLv3: "",
riskPoint: "",
judgeStandard: "",
regulation: "",
attachment: "",
status: "启用",
};
}
const enabledTotal = computed(
() =>
data.value?.categoryCounts.reduce((sum, item) => sum + item.count, 0) ?? 0,
);
const pageNumbers = computed(() => {
const total = data.value?.totalPages ?? 0;
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
const current = page.value;
const values = new Set([1, total, current, current - 1, current + 1]);
return [...values]
.filter((item) => item > 0 && item <= total)
.sort((a, b) => a - b);
});
const canManage = computed(() => auth.hasPermission("standard.manage"));
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await getStandards({
keyword: keyword.value.trim() || undefined,
category: category.value,
status: status.value,
page: page.value,
pageSize: 20,
});
if (data.value.totalPages > 0 && page.value > data.value.totalPages) {
page.value = data.value.totalPages;
}
} catch {
error.value = "标准库加载失败,请确认后端服务已启动。";
} finally {
loading.value = false;
}
}
function scheduleSearch() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
page.value = 1;
void load();
}, 300);
}
function selectCategory(value: string) {
category.value = value;
page.value = 1;
void load();
}
function changeStatus(value: string) {
status.value = value;
page.value = 1;
void load();
}
function goToPage(value: number) {
if (
!data.value ||
value < 1 ||
value > data.value.totalPages ||
value === page.value
)
return;
page.value = value;
void load();
}
async function openDetail(ruleId: number) {
detailLoading.value = true;
detailError.value = "";
detail.value = null;
try {
detail.value = await getStandard(ruleId);
} catch {
detailError.value = "标准详情加载失败,请重试。";
} finally {
detailLoading.value = false;
}
}
function closeDetail() {
detail.value = null;
detailError.value = "";
editing.value = false;
saveError.value = "";
}
function startCreate() {
detail.value = null;
form.value = emptyForm();
editing.value = true;
saveError.value = "";
}
function startEdit() {
if (!detail.value) return;
form.value = {
categoryLv1: detail.value.categoryLv1,
categoryLv2: detail.value.categoryLv2,
categoryLv3: detail.value.categoryLv3,
riskPoint: detail.value.riskPoint,
judgeStandard: detail.value.judgeStandard,
regulation: detail.value.regulation,
attachment: detail.value.attachment,
status: detail.value.status,
};
editing.value = true;
saveError.value = "";
}
async function saveForm() {
if (!form.value.riskPoint.trim() || !form.value.judgeStandard.trim()) {
saveError.value = "风险点和判定标准为必填项";
return;
}
saving.value = true;
saveError.value = "";
try {
if (detail.value) {
detail.value = await editStandard(detail.value.id, form.value);
} else {
detail.value = await addStandard(form.value);
}
editing.value = false;
await load();
} catch {
saveError.value = "保存失败,请确认当前账号具有管理员权限。";
} finally {
saving.value = false;
}
}
function categoryTone(value: StandardCategory) {
return categoryMeta[value]?.tone ?? "law";
}
function categoryDescription(value: StandardCategory) {
return categoryMeta[value]?.description ?? "";
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium" }).format(
new Date(value),
);
}
watch(keyword, scheduleSearch);
watch(
() => [route.query.category, route.query.keyword],
([categoryQuery, keywordQuery]) => {
const requestedCategory =
typeof categoryQuery === "string" ? categoryQuery : "all";
category.value = categories.some((item) => item.key === requestedCategory)
? requestedCategory
: "all";
keyword.value = typeof keywordQuery === "string" ? keywordQuery : "";
page.value = 1;
void load();
},
{ immediate: true },
);
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer);
});
</script>
<template>
<div class="standards-page">
<header class="standards-header">
<div>
<h1>判定标准库 <small>v1.1</small></h1>
<p class="page-description">
219 条判定标准,统一管理检测阈值、检测方法与法规依据。
</p>
</div>
<div class="header-meta">
<BookOpenCheck :size="17" />
<span>规则引擎已接入风险评估与 OCR 判定</span>
<button
v-if="canManage"
class="manage-button"
type="button"
@click="startCreate"
>
<BookOpenCheck :size="14" />新增标准
</button>
</div>
</header>
<section class="library-stats" aria-label="标准库统计">
<article class="library-total">
<span class="stat-icon"><BookOpenCheck :size="18" /></span>
<div>
<small>启用标准总数</small><strong>{{ enabledTotal || "--" }}</strong>
</div>
<span class="stat-note">规则持续维护</span>
</article>
<article
v-for="item in data?.categoryCounts ??
categories.slice(1).map((item) => ({
category: item.key as StandardCategory,
count: 0,
}))"
:key="item.category"
class="category-stat"
:class="`category-stat--${categoryTone(item.category)}`"
>
<span class="category-stat__dot"></span>
<div>
<small>{{ item.category }}</small
><strong>{{ item.count }}</strong>
</div>
<span class="category-stat__description">{{
categoryDescription(item.category)
}}</span>
</article>
</section>
<section class="library-toolbar">
<div class="search-field">
<Search :size="16" />
<input
v-model="keyword"
maxlength="50"
placeholder="搜索风险点、判定标准、法规依据或关键词"
aria-label="搜索标准库"
/>
<button
v-if="keyword"
type="button"
title="清除搜索"
aria-label="清除搜索"
@click="keyword = ''"
>
<X :size="14" />
</button>
</div>
<div class="toolbar-status">
<span>显示</span>
<button
:class="{ active: status === '启用' }"
type="button"
@click="changeStatus('启用')"
>
启用
</button>
<button
:class="{ active: status === 'all' }"
type="button"
@click="changeStatus('all')"
>
全部状态
</button>
</div>
</section>
<nav class="category-tabs" aria-label="标准类目筛选">
<button
v-for="item in categories"
:key="item.key"
type="button"
:class="{ active: category === item.key }"
@click="selectCategory(item.key)"
>
{{ item.label
}}<span v-if="item.key === 'all'"> {{ enabledTotal }}</span>
<span v-else>{{
data?.categoryCounts.find((count) => count.category === item.key)
?.count ?? 0
}}</span>
</button>
</nav>
<section class="results-panel">
<header class="results-header">
<div>
<h2>标准条目</h2>
<span v-if="data"
>共 {{ data.total }} 条 · 第 {{ data.page }} /
{{ data.totalPages || 1 }} 页</span
>
</div>
<button
class="refresh-button"
type="button"
title="刷新标准库"
aria-label="刷新标准库"
:disabled="loading"
@click="load"
>
<RefreshCw :size="15" :class="{ spinning: loading }" />
</button>
</header>
<div v-if="loading" class="library-state">
<LoaderCircle :size="20" class="spinning" />正在加载标准条目…
</div>
<div v-else-if="error" class="library-state library-state--error">
<AlertTriangle :size="19" />{{ error
}}<button type="button" @click="load">重试</button>
</div>
<div v-else-if="!data?.items.length" class="library-state">
<FileSearch :size="26" /><strong>未找到相关标准</strong
><span>请尝试更换关键词或筛选条件。</span>
</div>
<div v-else class="table-scroll">
<table class="standards-table">
<thead>
<tr>
<th>类目</th>
<th>风险点</th>
<th>判定标准</th>
<th>法规 / 数据来源</th>
<th>状态</th>
<th aria-label="操作"></th>
</tr>
</thead>
<tbody>
<tr
v-for="item in data.items"
:key="item.id"
tabindex="0"
@click="openDetail(item.id)"
@keydown.enter="openDetail(item.id)"
>
<td>
<span
class="category-tag"
:class="`category-tag--${categoryTone(item.categoryLv1)}`"
>{{ item.categoryLv1
}}<small v-if="item.categoryLv2">{{
item.categoryLv2
}}</small></span
>
</td>
<td>
<strong>{{ item.riskPoint }}</strong
><small v-if="item.categoryLv3">{{ item.categoryLv3 }}</small>
</td>
<td class="judge-cell">{{ item.judgeStandard }}</td>
<td class="source-cell">{{ item.regulation || "未填写" }}</td>
<td>
<span
class="status-tag"
:class="{ disabled: item.status === '停用' }"
>{{ item.status }}</span
>
</td>
<td>
<button
class="row-action"
type="button"
title="查看标准详情"
aria-label="查看标准详情"
@click.stop="openDetail(item.id)"
>
<ChevronRight :size="16" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="data && data.totalPages > 1" class="pagination">
<button
type="button"
title="上一页"
aria-label="上一页"
:disabled="page === 1"
@click="goToPage(page - 1)"
>
<ChevronLeft :size="15" />
</button>
<button
v-for="item in pageNumbers"
:key="item"
type="button"
:class="{ active: page === item }"
@click="goToPage(item)"
>
{{ item }}
</button>
<button
type="button"
title="下一页"
aria-label="下一页"
:disabled="page === data.totalPages"
@click="goToPage(page + 1)"
>
<ChevronRight :size="15" />
</button>
</footer>
</section>
<div
v-if="detail || detailLoading || detailError || editing"
class="detail-backdrop"
@click.self="closeDetail"
>
<aside class="detail-panel" aria-label="标准详情">
<header>
<div>
<p class="eyebrow">STANDARD DETAIL</p>
<h2>标准详情</h2>
</div>
<button
type="button"
title="关闭详情"
aria-label="关闭详情"
@click="closeDetail"
>
<X :size="18" />
</button>
</header>
<div v-if="detailLoading" class="detail-state">
<LoaderCircle :size="20" class="spinning" />正在加载详情…
</div>
<div v-else-if="detailError" class="detail-state detail-state--error">
<AlertTriangle :size="18" />{{ detailError }}
</div>
<template v-else-if="editing">
<form class="standard-form" @submit.prevent="saveForm">
<div class="form-grid">
<label
>一级类目<select v-model="form.categoryLv1">
<option
v-for="item in categories.slice(1)"
:key="item.key"
:value="item.key"
>
{{ item.label }}
</option>
</select></label
>
<label
>状态<select v-model="form.status">
<option value="启用">启用</option>
<option value="停用">停用</option>
</select></label
>
<label
>二级类目<input v-model.trim="form.categoryLv2" maxlength="60"
/></label>
<label
>三级类目<input v-model.trim="form.categoryLv3" maxlength="60"
/></label>
</div>
<label
>风险点 *<input v-model.trim="form.riskPoint" required
/></label>
<label
>判定标准 *<textarea
v-model.trim="form.judgeStandard"
required
rows="4"
></textarea>
</label>
<label
>法规 / 数据来源<input
v-model.trim="form.regulation"
maxlength="300"
/></label>
<label
>附件地址<input
v-model.trim="form.attachment"
maxlength="500"
placeholder="可填写标准原文或内部验收标准地址"
/></label>
<p v-if="saveError" class="form-error" role="alert">
{{ saveError }}
</p>
<div class="form-actions">
<button
type="button"
@click="detail ? (editing = false) : closeDetail()"
>
取消</button
><button class="save-button" type="submit" :disabled="saving">
<Check :size="14" />{{ saving ? "保存中…" : "保存标准" }}
</button>
</div>
</form>
</template>
<template v-else-if="detail">
<div class="detail-heading">
<span
class="category-tag"
:class="`category-tag--${categoryTone(detail.categoryLv1)}`"
>{{ detail.categoryLv1 }}</span
><span
class="status-tag"
:class="{ disabled: detail.status === '停用' }"
>{{ detail.status }}</span
><span class="detail-id"
>STD-{{ String(detail.id).padStart(4, "0") }}</span
>
</div>
<dl class="detail-list">
<div>
<dt>风险点</dt>
<dd>{{ detail.riskPoint }}</dd>
</div>
<div v-if="detail.categoryLv2">
<dt>二级类目</dt>
<dd>{{ detail.categoryLv2 }}</dd>
</div>
<div v-if="detail.categoryLv3">
<dt>三级类目</dt>
<dd>{{ detail.categoryLv3 }}</dd>
</div>
<div>
<dt>判定标准</dt>
<dd class="detail-rule">{{ detail.judgeStandard }}</dd>
</div>
<div>
<dt>法规 / 数据来源</dt>
<dd>{{ detail.regulation || "未填写" }}</dd>
</div>
<div>
<dt>最后更新</dt>
<dd>{{ formatDate(detail.updatedAt) }}</dd>
</div>
</dl>
<a
v-if="detail.attachment"
class="attachment-link"
:href="detail.attachment"
target="_blank"
rel="noreferrer"
><ExternalLink :size="14" />查看标准附件</a
>
<p class="detail-note">
该标准会在风险评估和 OCR 数值判定中作为规则依据引用。
</p>
<button
v-if="canManage"
class="edit-button"
type="button"
@click="startEdit"
>
编辑标准
</button>
</template>
</aside>
</div>
</div>
</template>
<style scoped>
.standards-page {
width: min(1320px, 100%);
margin: 0 auto;
padding: 28px 34px 48px;
overflow: auto;
}
.standards-header,
.results-header {
display: flex;
}
.results-header > div {
display: flex;
}
.standards-header,
.results-header {
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.standards-header {
margin-bottom: 22px;
}
.standards-header h1 {
margin: 4px 0 3px;
font-size: 24px;
font-weight: 750;
}
.standards-header h1 small {
color: var(--color-text-faint);
font-size: 11px;
font-weight: 500;
}
.page-description {
margin: 0;
color: var(--color-text-muted);
font-size: 11px;
}
.header-meta {
display: flex;
align-items: center;
gap: 7px;
margin-top: 16px;
color: var(--color-accent);
font-size: 11px;
}
.manage-button {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 8px;
border: 1px solid var(--color-text);
border-radius: 4px;
padding: 7px 10px;
background: var(--color-text);
color: white;
font-size: 11px;
}
.manage-button:hover {
background: #333;
}
.library-stats {
display: grid;
grid-template-columns: 1.15fr repeat(4, 1fr);
gap: 10px;
}
.library-total,
.category-stat {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
border: 1px solid var(--color-border);
border-radius: 7px;
padding: 14px;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.stat-icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border-radius: 6px;
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.library-total small,
.category-stat small {
display: block;
color: var(--color-text-muted);
font-size: 10px;
}
.library-total strong,
.category-stat strong {
display: block;
margin-top: 2px;
color: var(--color-text);
font-size: 21px;
line-height: 1.1;
}
.stat-note {
margin-left: auto;
color: var(--color-success);
font-size: 10px;
white-space: nowrap;
}
.category-stat__dot {
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
background: var(--color-text-faint);
}
.category-stat--material .category-stat__dot,
.category-tag--material {
background: var(--color-success-soft);
color: #15803d;
}
.category-stat--packaging .category-stat__dot,
.category-tag--packaging {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.category-stat--package .category-stat__dot,
.category-tag--package {
background: var(--color-warning-soft);
color: #b45309;
}
.category-stat--law .category-stat__dot,
.category-tag--law {
background: var(--color-purple-soft);
color: var(--color-purple);
}
.category-stat--material .category-stat__dot {
background: var(--color-success);
}
.category-stat--packaging .category-stat__dot {
background: var(--color-accent);
}
.category-stat--package .category-stat__dot {
background: var(--color-warning);
}
.category-stat--law .category-stat__dot {
background: var(--color-purple);
}
.category-stat__description {
overflow: hidden;
margin-left: auto;
color: var(--color-text-faint);
font-size: 9px;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-toolbar {
display: flex;
align-items: center;
gap: 14px;
margin-top: 22px;
}
.search-field {
display: flex;
min-height: 40px;
flex: 1;
align-items: center;
gap: 8px;
border: 1px solid var(--color-border-strong);
border-radius: 6px;
padding: 0 11px;
background: var(--color-surface);
color: var(--color-text-muted);
}
.search-field:focus-within {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-subtle);
}
.search-field input {
width: 100%;
min-height: 36px;
border: 0;
outline: 0;
padding: 0;
background: transparent;
color: var(--color-text);
font-size: 12px;
}
.search-field button,
.refresh-button,
.detail-panel header button {
display: grid;
place-items: center;
border: 0;
background: transparent;
color: var(--color-text-muted);
}
.search-field button:hover,
.refresh-button:hover,
.detail-panel header button:hover {
color: var(--color-danger);
}
.toolbar-status {
display: flex;
align-items: center;
gap: 5px;
color: var(--color-text-muted);
font-size: 11px;
}
.toolbar-status button {
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 7px 9px;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: 11px;
}
.toolbar-status button.active {
border-color: var(--color-text);
background: var(--color-text);
color: white;
}
.category-tabs {
display: flex;
gap: 3px;
margin-top: 13px;
border-bottom: 1px solid var(--color-border);
}
.category-tabs button {
border: 0;
border-bottom: 2px solid transparent;
padding: 10px 15px;
background: transparent;
color: var(--color-text-muted);
font-size: 12px;
}
.category-tabs button span {
margin-left: 4px;
color: var(--color-text-faint);
font-size: 10px;
}
.category-tabs button.active {
border-bottom-color: var(--color-text);
color: var(--color-text);
font-weight: 700;
}
.category-tabs button.active span {
color: var(--color-accent);
}
.results-panel {
margin-top: 14px;
border: 1px solid var(--color-border);
border-radius: 7px;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.results-header {
align-items: center;
border-bottom: 1px solid var(--color-border-light);
padding: 14px 16px;
}
.results-header h2 {
margin: 0;
font-size: 14px;
}
.results-header span {
margin-left: 9px;
color: var(--color-text-faint);
font-size: 10px;
}
.refresh-button {
width: 30px;
height: 30px;
border: 1px solid var(--color-border);
border-radius: 5px;
}
.refresh-button:disabled {
opacity: 0.45;
}
.table-scroll {
overflow-x: auto;
}
.standards-table {
width: 100%;
min-width: 850px;
border-collapse: collapse;
table-layout: fixed;
}
.standards-table th,
.standards-table td {
border-bottom: 1px solid var(--color-border-light);
padding: 12px 14px;
text-align: left;
vertical-align: middle;
}
.standards-table th {
background: var(--color-bg-warm);
color: var(--color-text-muted);
font-size: 10px;
font-weight: 650;
}
.standards-table th:nth-child(1) {
width: 14%;
}
.standards-table th:nth-child(2) {
width: 19%;
}
.standards-table th:nth-child(3) {
width: 27%;
}
.standards-table th:nth-child(4) {
width: 27%;
}
.standards-table th:nth-child(5) {
width: 8%;
}
.standards-table tr {
transition: background 0.15s ease;
}
.standards-table tbody tr {
cursor: pointer;
}
.standards-table tbody tr:hover,
.standards-table tbody tr:focus {
outline: 0;
background: var(--color-bg-warm);
}
.standards-table tbody tr:last-child td {
border-bottom: 0;
}
.standards-table td {
color: var(--color-text-secondary);
font-size: 11px;
}
.standards-table td > strong {
display: block;
color: var(--color-text);
font-size: 12px;
}
.standards-table td > small,
.category-tag small {
display: block;
margin-top: 3px;
color: var(--color-text-faint);
font-size: 9px;
font-weight: 400;
}
.category-tag {
display: inline-block;
border-radius: 4px;
padding: 4px 7px;
font-size: 10px;
font-weight: 700;
}
.judge-cell,
.source-cell {
color: var(--color-text-secondary);
line-height: 1.55;
}
.source-cell {
color: var(--color-text-muted);
}
.status-tag {
display: inline-block;
border: 1px solid var(--color-success-border);
border-radius: 999px;
padding: 3px 7px;
background: var(--color-success-soft);
color: var(--color-success);
font-size: 9px;
white-space: nowrap;
}
.status-tag.disabled {
border-color: var(--color-border);
background: var(--color-bg-warm);
color: var(--color-text-faint);
}
.row-action {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-text-faint);
}
.row-action:hover {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.library-state,
.detail-state {
display: flex;
min-height: 260px;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--color-text-muted);
font-size: 12px;
}
.library-state strong,
.library-state span {
display: block;
}
.library-state {
flex-direction: column;
}
.library-state--error,
.detail-state--error {
flex-direction: row;
flex-wrap: wrap;
color: var(--color-danger);
}
.library-state button {
border: 1px solid var(--color-danger-border);
border-radius: 4px;
padding: 5px 9px;
background: white;
color: var(--color-danger);
font-size: 11px;
}
.pagination {
display: flex;
justify-content: center;
gap: 5px;
border-top: 1px solid var(--color-border-light);
padding: 13px;
}
.pagination button {
display: grid;
min-width: 28px;
height: 28px;
place-items: center;
border: 1px solid var(--color-border);
border-radius: 4px;
background: white;
color: var(--color-text-muted);
font-size: 11px;
}
.pagination button.active {
border-color: var(--color-text);
background: var(--color-text);
color: white;
}
.pagination button:disabled {
cursor: not-allowed;
opacity: 0.35;
}
.detail-backdrop {
position: fixed;
z-index: 20;
inset: 0;
display: flex;
justify-content: flex-end;
background: rgb(17 17 17 / 28%);
}
.detail-panel {
width: min(470px, 100%);
height: 100%;
overflow-y: auto;
padding: 26px;
background: var(--color-surface);
box-shadow: -5px 0 20px rgb(0 0 0 / 10%);
}
.detail-panel > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 17px;
}
.detail-panel h2 {
margin: 4px 0 0;
font-size: 20px;
}
.detail-panel header button {
width: 32px;
height: 32px;
border-radius: 5px;
}
.detail-heading {
display: flex;
align-items: center;
gap: 9px;
margin: 22px 0;
}
.detail-id {
margin-left: auto;
color: var(--color-text-faint);
font-size: 10px;
}
.detail-list {
display: grid;
gap: 0;
margin: 0;
}
.detail-list > div {
border-bottom: 1px solid var(--color-border-light);
padding: 13px 0;
}
.detail-list dt {
margin-bottom: 5px;
color: var(--color-text-faint);
font-size: 10px;
}
.detail-list dd {
margin: 0;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.detail-list .detail-rule {
color: var(--color-text);
font-weight: 650;
}
.attachment-link {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 20px;
color: var(--color-accent);
font-size: 11px;
text-decoration: none;
}
.detail-note {
margin-top: 24px;
border-left: 3px solid var(--color-accent);
padding-left: 10px;
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.7;
}
.edit-button {
margin-top: 20px;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
padding: 8px 12px;
background: white;
color: var(--color-text-secondary);
font-size: 11px;
}
.edit-button:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.standard-form {
display: grid;
gap: 14px;
margin-top: 20px;
}
.standard-form label {
display: grid;
gap: 6px;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 650;
}
.standard-form input,
.standard-form select,
.standard-form textarea {
width: 100%;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 8px 9px;
background: white;
color: var(--color-text);
font-size: 12px;
}
.standard-form textarea {
resize: vertical;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 7px;
margin-top: 4px;
}
.form-actions button {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
padding: 7px 11px;
background: white;
color: var(--color-text-secondary);
font-size: 11px;
}
.form-actions .save-button {
border-color: var(--color-text);
background: var(--color-text);
color: white;
}
.form-actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.form-error {
margin: 0;
color: var(--color-danger);
font-size: 11px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 1050px) {
.library-stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.library-total {
grid-column: 1 / -1;
}
.category-stat__description {
display: none;
}
}
@media (max-width: 760px) {
.standards-page {
padding: 20px 12px 36px;
}
.standards-header {
flex-direction: column;
gap: 0;
}
.header-meta {
margin-top: 8px;
}
.library-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.library-total {
grid-column: 1 / -1;
}
.library-toolbar {
align-items: stretch;
flex-direction: column;
}
.toolbar-status {
justify-content: flex-end;
}
.category-tabs {
overflow-x: auto;
}
.category-tabs button {
min-width: max-content;
}
.standards-table th,
.standards-table td {
padding: 10px;
}
.detail-panel {
padding: 22px 18px;
}
.header-meta {
align-items: flex-start;
flex-wrap: wrap;
}
.manage-button {
margin-left: 0;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>