1304 lines
31 KiB
Vue
1304 lines
31 KiB
Vue
<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>
|