chore: initialize GSREA website project

This commit is contained in:
zlwu committed 2026-10-30 11:05:55 +08:00
commit d3c5461de5
1842 files changed
+246140

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
+396
View File
@@ -0,0 +1,396 @@
.data {
--logo: url("site-assets/基础数据/logo/logo.jpg");
--slide1: url("site-assets/基础数据/首页轮播/58612080.jpg");
--slide2: url("site-assets/基础数据/首页轮播/50933127.jpg");
--slide3: url("site-assets/基础数据/首页轮播/49922210.jpg");
--dash: url("site-assets/基础数据/新闻动态/文章/10837751/images/173933407.png");
--honor1: url("site-assets/images/8488214.jpg");
--honor2: url("site-assets/images/8488215.jpg");
font-family: "Source Han Sans SC", "PingFang SC", "Noto Sans SC", sans-serif;
color: var(--text);
background: var(--bg);
}
.data a { color: inherit; text-decoration: none; }
.wrap { width: min(1140px, calc(100% - 32px)); margin: 0 auto; }
.list { margin: 0; padding: 0; list-style: none; }
.list li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.list time { flex: none; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.list a:hover { color: var(--c); }
.vscroll { overflow: hidden; }
.vtrack { animation: rise linear infinite; }
.vscroll:hover .vtrack,
.vscroll:focus-within .vtrack { animation-play-state: paused; }
@keyframes rise { to { transform: translateY(-50%); } }
@media (prefers-reduced-motion: reduce) {
.vtrack { animation: none; }
}
.section-title { margin: 0 0 12px; font-size: 18px; font-weight: 600; letter-spacing: .06em; }
.with-mark { display: flex; align-items: center; gap: 8px; }
.title-mark { width: 22px; height: 22px; flex: none; }
.panel { background: var(--surface); border: 1px solid var(--line); }
.marquee { overflow: hidden; margin-top: 16px; background: var(--surface); border: 1px solid var(--line); }
.track { display: flex; gap: 16px; width: max-content; animation: slide 32s linear infinite; padding: 14px; }
@keyframes slide { to { transform: translateX(-50%); } }
.member {
width: 220px; display: flex; align-items: center; gap: 10px;
padding: 10px 12px; background: var(--chip); border: 1px solid var(--line); font-size: 13px;
}
.member i {
width: 36px; height: 36px; flex: none; border-radius: 4px;
background: var(--c) center/cover no-repeat; opacity: .85;
}
.honor {
width: 140px; height: 72px; background: center/cover no-repeat var(--honor1);
border: 1px solid var(--line); position: relative;
}
.honor.b { background-image: var(--honor2); }
.honor span {
position: absolute; inset: auto 0 0; padding: 4px 6px;
background: rgba(0,0,0,.45); color: #fff; font-size: 11px;
}
footer.site { margin-top: 28px; padding: 28px 0 28px; background: var(--foot); color: var(--foot-fg); font-size: 13px; }
footer.site h3 { margin: 0 0 10px; font-size: 14px; letter-spacing: .08em; }
footer.site a { display: block; padding: 3px 0; opacity: .92; }
footer.site a:hover { opacity: 1; }
.foot { display: grid; grid-template-columns: 1.15fr 1.35fr .85fr; gap: 24px; }
.map {
margin-top: 10px; height: 110px; display: grid; place-items: center;
background: linear-gradient(135deg, rgba(255,255,255,.08), transparent), var(--map);
border: 1px solid rgba(255,255,255,.12); font-size: 12px;
}
.icp { margin-top: 16px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.14); font-size: 12px; opacity: .8; }
/* ---------- 科技蓝 ---------- */
#tech {
--c: #1677ff;
--c2: #00c2ff;
--bg: #f0f5ff;
--surface: #ffffff;
--text: #0b1f3a;
--muted: #5b6b85;
--line: #d6e4ff;
--chip: #f7faff;
--foot: #041833;
--foot-fg: #d9e8ff;
--map: #0a2a55;
}
#tech .glow {
background:
radial-gradient(900px 280px at 12% -10%, rgba(22,119,255,.22), transparent 60%),
radial-gradient(700px 240px at 90% 0%, rgba(0,194,255,.16), transparent 55%),
var(--bg);
}
#tech header.mast {
background: rgba(255,255,255,.86);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 5;
}
#tech .mast-row {
display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 0 8px;
}
#tech .brand { display: flex; align-items: center; gap: 14px; min-width: 0; flex: 1; }
#tech .brand h1 { overflow-wrap: anywhere; }
#tech .slogan {
text-align: right; flex: 0 0 auto;
padding-left: 22px; margin-left: 8px; border-left: 3px solid #0b3a66;
font-family: "Source Han Serif SC", "Songti SC", "Noto Serif SC", serif;
}
#tech .slogan h2 {
margin: 0; font-size: 26px; font-weight: 700; letter-spacing: .06em;
color: #0b2a4a; line-height: 1.3; white-space: nowrap;
}
#tech .slogan p {
margin: 6px 0 0; color: #0b3a66; font-size: 15px; font-weight: 600; letter-spacing: .16em;
white-space: nowrap;
}
#tech .mark {
width: 52px; height: 52px; border-radius: 14px;
background: var(--logo) center/cover no-repeat;
box-shadow: 0 0 0 1px rgba(22,119,255,.25), 0 8px 20px rgba(22,119,255,.18);
}
#tech .brand h1 { margin: 0; font-size: 22px; font-weight: 650; letter-spacing: .04em; }
#tech .brand .en {
margin: 4px 0 0; color: var(--muted); font-size: 11px; letter-spacing: .06em;
overflow-wrap: anywhere; text-transform: uppercase;
}
#tech nav.menu {
display: flex; flex-wrap: nowrap; gap: 4px; padding-bottom: 0;
overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain;
scrollbar-width: thin;
}
#tech nav.menu a {
flex: none; white-space: nowrap;
padding: 10px 16px; color: var(--muted); border-bottom: 2px solid transparent; font-size: 14px;
}
#tech nav.menu a[aria-current="page"] { color: var(--c); border-color: var(--c); font-weight: 600; }
#tech a.hero,
#brand a.hero {
display: block;
color: inherit;
text-decoration: none;
cursor: pointer;
}
#tech .hero,
#brand .hero { background-image: none; }
#tech .hero {
margin-top: 18px;
position: relative; height: 360px; border-radius: 18px; overflow: hidden;
background: var(--slide1) center/cover no-repeat;
box-shadow: 0 18px 40px rgba(10,40,100,.18);
}
#tech .hero::after {
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 38%;
background: linear-gradient(transparent, rgba(4,24,51,.55));
}
#tech .hero .cap {
position: absolute; z-index: 1; left: 28px; bottom: 28px; right: 28px; color: #fff;
}
#tech .hero h3 { margin: 0; font-size: 28px; font-weight: 650; }
#tech .hero span {
display: inline-block; margin-top: 8px; padding: 4px 10px; border-radius: 999px;
background: rgba(0,194,255,.2); border: 1px solid rgba(0,194,255,.45); font-size: 12px;
}
#tech .cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 16px; }
#tech .col { padding: 16px; border-radius: 14px; min-height: 250px; }
#tech .col h3 { color: var(--c); }
#tech .service-row {
margin-top: 14px;
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(300px, .95fr);
gap: 12px;
align-items: stretch;
}
#tech .side { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
#tech .cats { display: flex; flex-direction: column; gap: 8px; flex: 1; }
#tech .cat {
background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 10px 12px;
}
#tech .cat h3 {
margin: 0 0 6px; font-size: 14px; letter-spacing: .12em; color: var(--c);
}
#tech .cat a {
display: block; padding: 4px 0; font-size: 13px; color: var(--text); border-bottom: 1px dashed #e6eeff;
}
#tech .cat a:last-child { border-bottom: 0; }
#tech .cat a:hover { color: var(--c); }
#tech .hot {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
#tech .hot a {
display: grid; place-items: center; text-align: center;
min-height: 40px; padding: 8px 6px; border-radius: 8px;
font-size: 14px; letter-spacing: .04em;
color: #fff; background: linear-gradient(160deg, #1677ff, #0a4fd6);
}
#tech .board {
padding: 14px; border-radius: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 100%;
}
#tech .board-art {
min-height: 180px; border-radius: 12px;
background: var(--dash) center/cover no-repeat; position: relative; overflow: hidden;
}
#tech .board-art::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(transparent 30%, rgba(4,24,51,.72));
}
#tech .board-art strong {
position: absolute; z-index: 1; left: 14px; right: 14px; bottom: 14px;
color: #fff; font-size: 15px; font-weight: 600;
}
#tech .board .list li { border-color: #e8efff; }
/* ---------- LOGO 蓝 ---------- */
#brand {
--c: #0858a3;
--c2: #064a86;
--bg: #ffffff;
--surface: #ffffff;
--text: #1c2430;
--muted: #6d645b;
--line: #e6e8eb;
--chip: #ffffff;
--r: 12px;
--foot: #06243f;
--foot-fg: #efe7da;
--map: #0a3a66;
--brass: #b08948;
font-family: "Source Han Sans SC", "PingFang SC", "Noto Sans SC", sans-serif;
}
#brand header.mast { background: var(--surface); color: var(--text); }
#brand .brand-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
column-gap: 28px;
width: min(1140px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 22px;
}
#brand .brand-head::before { content: ""; grid-column: 1; }
#brand .brand-head > .wrap {
grid-column: 2;
width: auto;
max-width: 100%;
margin: 0;
min-width: 0;
}
#brand .mast-row { padding: 0; text-align: center; }
#brand .crest {
width: 76px; height: 76px; margin: 0 auto 12px; border-radius: 50%;
background: #fff var(--logo) center/78% no-repeat;
box-shadow: 0 0 0 1px #e3d9cc, 0 0 0 6px #fff, 0 0 0 7px var(--brass);
}
#brand header.mast h1 {
margin: 0;
font-family: "Source Han Serif SC", "Songti SC", "Noto Serif SC", serif;
font-size: 30px; font-weight: 600; letter-spacing: .16em; color: #12304f;
}
#brand header.mast h1::after {
content: ""; display: block; width: 64px; height: 2px; margin: 14px auto 0;
background: linear-gradient(90deg, transparent, var(--brass), transparent);
}
#brand header.mast .en {
margin: 10px 0 0; letter-spacing: .06em; font-size: 11px; color: var(--muted);
overflow-wrap: anywhere; text-transform: uppercase;
}
#brand .slogan {
grid-column: 3;
justify-self: end;
align-self: center;
width: max-content;
max-width: 100%;
min-width: 0;
margin: 0;
text-align: right;
padding-left: 16px;
border-left: 3px solid var(--brass);
font-family: "Source Han Serif SC", "Songti SC", "Noto Serif SC", serif;
}
#brand .slogan h2,
#brand .slogan p { display: block; text-align: right; white-space: normal; }
#brand .slogan h2 {
margin: 0; font-size: 17px; font-weight: 600; letter-spacing: 0;
color: #12304f; line-height: 1.35;
}
#brand .slogan p {
margin: 8px 0 0; font-size: 15px; letter-spacing: .06em; color: var(--c);
}
#brand nav.menu {
display: flex; justify-content: center; flex-wrap: nowrap; gap: 8px 36px;
background: var(--c); color: #f7f3eb;
overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain;
scrollbar-width: thin;
}
#brand nav.menu a {
flex: none; white-space: nowrap;
padding: 14px 4px; letter-spacing: .18em; font-size: 15px;
}
#brand nav.menu a[aria-current="page"] { box-shadow: inset 0 -3px 0 #e4c98a; }
#brand .hero {
margin-top: 22px; height: 340px; background: var(--slide2) center/cover no-repeat;
position: relative; border: 1px solid var(--line);
border-radius: var(--r); overflow: hidden;
}
#brand .hero .cap {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 12px 16px; background: rgba(6, 36, 63, .82); color: #fff;
}
#brand .hero h3 { margin: 0; font-size: 16px; font-weight: 500; letter-spacing: .04em; line-height: 1.45; }
#brand .sheet {
margin-top: 16px;
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
}
#brand .cell {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); padding: 14px 14px 4px; min-width: 0;
}
#brand .cell h3 {
margin: 0 0 10px;
font-family: "Source Han Serif SC", "Songti SC", "Noto Serif SC", serif;
font-size: 17px; font-weight: 600; letter-spacing: .16em; color: #12304f;
}
#brand .cover {
display: block; height: 176px; position: relative; overflow: hidden;
border-radius: 10px; background: #12304f center/cover no-repeat;
}
#brand .cover strong {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 10px 12px; background: rgba(6, 36, 63, .82);
color: #fff; font-size: 14px; font-weight: 500; line-height: 1.45;
}
#brand .gate { margin-top: 22px; }
#brand .band {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
}
#brand .band a {
text-align: center; padding: 14px 8px;
font-size: 15px; letter-spacing: .18em; font-weight: 600;
color: #fff; background: var(--c); border-radius: 10px;
}
#brand .band a:hover { background: #064a86; }
#brand .rows {
margin-top: 14px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; background: #fff;
}
#brand .rows .cat {
background: transparent; border: 0; border-radius: 0; box-shadow: none;
padding: 18px 22px 14px;
}
#brand .rows .cat + .cat { border-left: 1px solid var(--line); }
#brand .rows h3 {
margin: 0 0 12px; padding: 0 0 10px;
font-family: "Source Han Serif SC", "Songti SC", "Noto Serif SC", serif;
font-size: 17px; font-weight: 600; letter-spacing: .18em;
color: #12304f; background: transparent; border: 0;
border-bottom: 2px solid var(--c);
}
#brand .rows a {
display: block; margin: 0; padding: 8px 0;
border: 0; font-size: 14px; color: #334155; line-height: 1.5;
}
#brand .rows a:hover { color: var(--c); }
#brand .rail {
margin: 22px 0 0;
font-family: "Source Han Serif SC", "Songti SC", "Noto Serif SC", serif;
font-size: 18px; font-weight: 600; letter-spacing: .16em; color: #12304f;
}
#brand .marquee { background: var(--surface); border-radius: var(--r); }
#brand .honor { border-radius: 8px; overflow: hidden; }
#brand .member { border-radius: 10px; }
#brand .map { border-radius: 10px; }
#brand .honor span { background: rgba(6,36,63,.62); letter-spacing: .08em; }
@media (max-width: 860px) {
#tech .cols, #tech .service-row, #tech .foot,
#brand .sheet, #brand .foot, #brand .rows { grid-template-columns: 1fr; }
#brand .hero { height: 220px; }
#tech .hero { height: 220px; border-radius: 12px; }
#tech .mast-row { flex-direction: column; align-items: flex-start; }
#tech .slogan { text-align: left; max-width: none; padding-left: 0; border-left: 0; border-top: 3px solid #0b3a66; padding-top: 10px; }
#tech .slogan h2 { font-size: 22px; letter-spacing: .08em; }
#tech .brand h1 { font-size: 17px; }
#brand .brand-head {
display: flex;
flex-direction: column;
align-items: center;
width: min(1140px, calc(100% - 32px));
}
#brand .brand-head::before { content: none; }
#brand .brand-head > .wrap { width: 100%; }
#brand header.mast h1 { font-size: 20px; letter-spacing: .08em; }
#brand .slogan {
width: auto;
max-width: 100%;
margin-top: 16px;
text-align: center;
border-left: 0;
padding-left: 0;
border-top: 3px solid var(--brass);
padding-top: 12px;
}
#brand .slogan h2, #brand .slogan p { text-align: center; }
#brand nav.menu { justify-content: flex-start; padding: 0 16px; gap: 8px 24px; }
#brand .band { grid-template-columns: 1fr; }
#brand .rows .cat + .cat { border-left: 0; border-top: 1px solid var(--line); }
}
+33
View File
@@ -0,0 +1,33 @@
:root,
[data-theme="formal"] {
--color-primary: #0b3a66;
--color-bg: #f3f5f7;
--color-surface: #ffffff;
--color-text: #1c2833;
--color-muted: #5c6b7a;
}
[data-theme="solemn"] {
--color-primary: #3d2b1f;
--color-bg: #f6f3ee;
--color-surface: #fffdf9;
--color-text: #241c16;
--color-muted: #6d6258;
}
* {
box-sizing: border-box;
}
html,
body,
#__nuxt {
margin: 0;
min-height: 100%;
}
body {
font-family: "Source Han Serif SC", "Songti SC", "Noto Serif SC", serif;
background: var(--color-bg);
color: var(--color-text);
}
+49
View File
@@ -0,0 +1,49 @@
<template>
<div class="boot" role="status" aria-live="polite" aria-busy="true">
<span class="spinner" aria-hidden="true" />
<p class="hint">{{ hint }}</p>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ hint?: string }>(), {
hint: "正在加载",
})
</script>
<style scoped>
.boot {
min-height: 100vh;
display: grid;
place-content: center;
justify-items: center;
gap: 16px;
background: #fff;
}
.spinner {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #e5e5e5;
border-top-color: var(--color-primary, #0b3a66);
animation: spin 0.8s linear infinite;
}
.hint {
margin: 0;
color: #666;
font-size: 14px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
border-color: var(--color-primary, #0b3a66);
}
}
</style>
@@ -0,0 +1,296 @@
<template>
<div>
<div class="page-head">
<h2 class="page-title">{{ title }}</h2>
<slot name="actions" />
</div>
<p v-if="loading" class="hint">列表加载中…</p>
<template v-else-if="region">
<p v-if="region.placeholder" class="placeholder">{{ region.placeholder }}</p>
<template v-else>
<ul v-if="region.items?.length" :class="showCover ? 'card-list' : 'list'">
<li v-for="item in region.items" :key="item.id">
<NuxtLink
v-if="showCover"
class="row"
:to="`/articles/${item.id}`"
>
<div
class="thumb"
:class="{ 'is-empty': !item.imageUrl }"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
:role="item.imageUrl ? 'img' : undefined"
:aria-label="item.imageUrl ? '封面' : '无封面'"
/>
<div class="meta">
<strong>{{ item.title }}</strong>
</div>
</NuxtLink>
<NuxtLink v-else :to="`/articles/${item.id}`">{{ item.title }}</NuxtLink>
</li>
</ul>
<nav v-if="showPager" class="pager" aria-label="列表分页">
<div class="pager-meta">共 {{ region.total }} 条 · 第 {{ region.current }}/{{ pageCount }} 页</div>
<div class="pager-controls">
<div class="pager-nav">
<button type="button" :disabled="region.current <= 1" @click="goPage(region.current - 1)">上一页</button>
<template v-for="(item, index) in visiblePages" :key="`${item}-${index}`">
<span v-if="item === 'ellipsis'" class="pager-ellipsis" aria-hidden="true">…</span>
<button
v-else
type="button"
:aria-current="item === region.current ? 'page' : undefined"
@click="goPage(item)"
>{{ item }}</button>
</template>
<button type="button" :disabled="region.current >= pageCount" @click="goPage(region.current + 1)">下一页</button>
</div>
<label class="pager-size">每页
<select :value="pageSize" aria-label="每页条数" @change="onSizeChange">
<option v-for="n in SIZE_OPTIONS" :key="n" :value="n">{{ n }} 条/页</option>
</select>
</label>
</div>
</nav>
</template>
</template>
</div>
</template>
<script setup lang="ts">
const SIZE_OPTIONS = [10, 20, 100] as const
const DEFAULT_SIZE = 10
const props = withDefaults(defineProps<{ title: string; category: string; showCover?: boolean }>(), {
showCover: false,
})
const route = useRoute()
const router = useRouter()
const { get } = useSiteApi()
const region = ref<any>(null)
const loading = ref(true)
const currentPage = computed(() => {
const raw = Number(route.query.page || 1)
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1
})
const pageSize = computed(() => {
const raw = Number(route.query.size || DEFAULT_SIZE)
return (SIZE_OPTIONS as readonly number[]).includes(raw) ? raw : DEFAULT_SIZE
})
const pageCount = computed(() => {
const total = Number(region.value?.total || 0)
const size = Number(region.value?.size || pageSize.value)
return Math.max(1, Math.ceil(total / size))
})
const showPager = computed(() => Number(region.value?.total || 0) > 0)
/** 页码过多时只保留首页、末页与当前页附近,中间用省略号。 */
const visiblePages = computed(() => {
const total = pageCount.value
const current = Math.min(Math.max(1, Number(region.value?.current || 1)), total)
if (total <= 5) return Array.from({ length: total }, (_, i) => i + 1) as Array<number | 'ellipsis'>
const picked = new Set<number>([1, total, current])
for (let i = current - 1; i <= current + 1; i++) {
if (i >= 1 && i <= total) picked.add(i)
}
const sorted = [...picked].sort((a, b) => a - b)
const items: Array<number | 'ellipsis'> = []
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i]! - sorted[i - 1]! > 1) items.push('ellipsis')
items.push(sorted[i]!)
}
return items
})
async function load() {
loading.value = true
try {
region.value = await get<any>(
`/about/articles/${props.category}?current=${currentPage.value}&size=${pageSize.value}`,
)
} finally {
loading.value = false
}
}
function goPage(page: number) {
const query: Record<string, string> = { ...route.query } as Record<string, string>
if (page <= 1) delete query.page
else query.page = String(page)
if (pageSize.value === DEFAULT_SIZE) delete query.size
else query.size = String(pageSize.value)
router.push({ query })
}
function onSizeChange(event: Event) {
const value = Number((event.target as HTMLSelectElement).value)
const size = (SIZE_OPTIONS as readonly number[]).includes(value) ? value : DEFAULT_SIZE
const query: Record<string, string> = { ...route.query } as Record<string, string>
delete query.page
if (size === DEFAULT_SIZE) delete query.size
else query.size = String(size)
router.push({ query })
}
watch(
() => [props.category, currentPage.value, pageSize.value] as const,
() => {
void load()
},
{ immediate: true },
)
</script>
<style scoped>
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 18px;
}
.page-title { margin: 0; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
border: 1px dashed var(--line, #d6e4ff);
}
.hint {
margin: 12px 0 0; text-align: center; color: var(--muted, #5b6b85); font-size: 14px;
}
.list { margin: 0; padding: 0; list-style: none; }
.list li {
padding: 12px 0;
border-bottom: 1px solid var(--line, #d6e4ff);
font-size: 14px;
}
.list a { color: inherit; text-decoration: none; }
.list a:hover { color: var(--c, #1677ff); }
.card-list {
margin: 0; padding: 0; list-style: none;
}
.card-list li {
border-bottom: 1px solid var(--line, #d6e4ff);
}
.card-list li:last-child { border-bottom: 0; }
.card-list .row {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 16px;
align-items: center;
padding: 14px 0;
color: inherit;
text-decoration: none;
}
.card-list .thumb {
width: 112px;
height: 72px;
border-radius: 8px;
background: var(--chip, #f7faff) center/cover no-repeat;
border: 1px solid var(--line, #d6e4ff);
}
.card-list .thumb.is-empty {
background: var(--chip, #f7faff) url("/cover-empty.png") center / contain no-repeat;
border-color: transparent;
}
.card-list .meta {
min-width: 0;
display: flex;
align-items: center;
}
.card-list strong { font-size: 15px; font-weight: 600; line-height: 1.45; }
.card-list .row:hover strong { color: var(--c, #1677ff); }
.pager {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-top: 22px;
padding-top: 16px;
border-top: 1px solid var(--line, #d6e4ff);
}
.pager-meta {
font-size: 12px;
color: var(--muted, #5b6b85);
text-align: center;
}
.pager-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 10px 14px;
width: 100%;
}
.pager-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px;
}
.pager button, .pager select {
min-width: 36px; height: 34px; padding: 0 12px;
border: 1px solid var(--line, #d6e4ff);
background: var(--surface, #fff);
color: var(--text, #0b1f3a);
border-radius: 8px;
font: inherit;
cursor: pointer;
}
.pager select {
min-width: 118px;
max-width: 100%;
padding: 0 28px 0 12px;
appearance: none;
-webkit-appearance: none;
background-color: var(--surface, #fff);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%235b6b85' d='M2.2 4.2 6 8l3.8-3.8-.9-.9L6 6.2 3.1 3.3z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px;
}
.pager button:hover:not(:disabled) { border-color: var(--c, #1677ff); color: var(--c, #1677ff); }
.pager button[aria-current="page"] {
background: var(--c, #1677ff);
border-color: var(--c, #1677ff);
color: #fff;
}
.pager button:disabled { opacity: 0.4; cursor: not-allowed; }
.pager-size {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted, #5b6b85);
white-space: nowrap;
}
.pager-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 34px;
color: var(--muted, #5b6b85);
letter-spacing: 0.08em;
user-select: none;
}
@media (max-width: 860px) {
.card-list .row {
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
}
.card-list .thumb { width: 88px; height: 58px; }
.pager-controls { gap: 12px; }
.pager-size {
width: 100%;
justify-content: center;
}
}
</style>
@@ -0,0 +1,35 @@
<template>
<section v-if="files?.length" class="files">
<h3>附件</h3>
<ul>
<li v-for="file in files" :key="file.url">
<a :href="file.url" :download="file.filename" rel="noopener">{{ file.filename }}</a>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
defineProps<{ files?: { filename: string; url: string }[] }>()
</script>
<style scoped>
.files {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--line, #e5e7eb);
}
.files h3 {
margin: 0 0 10px;
font-size: 14px;
letter-spacing: 0.08em;
color: var(--muted, #5b6b85);
}
.files ul {
margin: 0;
padding: 0;
list-style: none;
}
.files li + li { margin-top: 8px; }
.files a { color: var(--c, #1677ff); }
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<SiteLoading v-if="!ready" hint="正在读取内容" />
<SiteHome v-else-if="home" :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout" :class="{ 'no-side': !showSide }">
<nav v-if="showSide" class="side-nav" aria-label="组织概况子导航">
<NuxtLink
v-for="section in ABOUT_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel">
<slot />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { ABOUT_SECTIONS } from "~/composables/useAboutNav"
withDefaults(
defineProps<{
home: any
logoUrl: string
ready: boolean
crumb: string
activeKey?: string
showSide?: boolean
}>(),
{ showSide: true },
)
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.layout.no-side {
grid-template-columns: 1fr;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
.placeholder {
margin: 0;
padding: 48px 20px;
text-align: center;
color: var(--muted, #5b6b85);
font-size: 15px;
letter-spacing: 0.06em;
border: 1px dashed var(--line, #d6e4ff);
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<section :class="kind === 'brand' ? 'cell' : 'col panel'">
<h3 class="with-mark" :class="kind === 'brand' ? '' : 'section-title'">
<TitleMark :name="title" />
{{ title }}
</h3>
<NuxtLink
v-if="cover && coverItem"
class="cover"
:style="{ backgroundImage: `url(${coverItem.imageUrl})` }"
:to="`/articles/${coverItem.id}`"
>
<strong>{{ coverItem.title }}</strong>
</NuxtLink>
<p v-if="region.placeholder">{{ region.placeholder }}</p>
<VerticalList v-else :items="region.items" :interval-seconds="region.intervalSeconds" />
</section>
</template>
<script setup lang="ts">
import TitleMark from "./TitleMark.vue"
const props = defineProps<{ title: string; region: any; kind?: "tech" | "brand"; cover?: boolean }>()
const coverItem = computed(() => (props.cover ? (props.region.items || []).find((item: any) => item.imageUrl) : null))
</script>
@@ -0,0 +1,28 @@
<template>
<section v-if="home.audiences.placeholder" class="cats">
<p>{{ home.audiences.placeholder }}</p>
</section>
<section v-else class="cats" :class="{ rows: brand }" aria-label="服务对象入口">
<div v-for="category in home.audiences.categories" :key="category.name" class="cat">
<h3 class="with-mark">
<TitleMark :name="category.name" />
{{ category.name }}
</h3>
<a
v-for="link in category.links"
:key="link.label"
:href="link.navigation === 'none' ? undefined : link.url"
:target="link.navigation === 'newWindow' ? '_blank' : undefined"
rel="noopener"
@click="link.navigation === 'none' && $event.preventDefault()"
>{{ link.label }}</a>
</div>
</section>
</template>
<script setup lang="ts">
import TitleMark from "./TitleMark.vue"
const props = defineProps<{ home: any }>()
const brand = computed(() => props.home.templateName === "LOGO蓝")
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<p v-if="home.hotLinks.placeholder">{{ home.hotLinks.placeholder }}</p>
<nav v-else :class="brand ? 'band' : 'hot'" aria-label="高频功能入口">
<a
v-for="link in home.hotLinks.links"
:key="link.label"
:href="link.navigation === 'none' ? undefined : link.url"
:target="link.navigation === 'newWindow' ? '_blank' : undefined"
rel="noopener"
@click="link.navigation === 'none' && $event.preventDefault()"
>{{ link.label }}</a>
</nav>
</template>
<script setup lang="ts">
const props = defineProps<{ home: any }>()
const brand = computed(() => props.home.templateName === "LOGO蓝")
</script>
+318
View File
@@ -0,0 +1,318 @@
<template>
<div
v-if="home"
:id="variant"
class="data"
:class="{ glow: variant === 'tech' }"
:style="logoStyle"
>
<header class="mast">
<div v-if="variant === 'tech'" class="wrap">
<div class="mast-row">
<div class="brand">
<div class="mark" role="img" aria-label="协会标志" />
<div>
<h1>甘肃省房地产价格评估行业协会</h1>
<p class="en">GAN SU SHENG FANG DI CHAN JIA GE PING GU HANG YE XIE HUI</p>
</div>
</div>
<div class="slogan">
<h2>{{ home.slogan }}</h2>
<p>{{ home.valueProposition }}</p>
</div>
</div>
</div>
<div v-else class="brand-head">
<div class="wrap mast-row">
<div class="identity">
<div class="crest" role="img" aria-label="协会标志" />
<div>
<h1>甘肃省房地产价格评估行业协会</h1>
<p class="en">GAN SU SHENG FANG DI CHAN JIA GE PING GU HANG YE XIE HUI</p>
</div>
</div>
</div>
<div class="slogan">
<h2>{{ home.slogan }}</h2>
<p>{{ home.valueProposition }}</p>
</div>
</div>
<div :class="variant === 'tech' ? 'wrap' : ''">
<nav class="menu">
<template v-for="item in topMenu" :key="`${item.label}-${item.url}`">
<NuxtLink
v-if="item.navigation === 'sameWindow' && isInternal(item.url)"
:to="item.url"
:aria-current="isCurrentNav(item.url) ? 'page' : undefined"
>{{ item.label }}</NuxtLink>
<a
v-else
:href="item.navigation === 'none' ? undefined : item.url"
:aria-current="isCurrentNav(item.url) ? 'page' : undefined"
:target="item.navigation === 'newWindow' ? '_blank' : undefined"
:rel="item.navigation === 'newWindow' ? 'noopener' : undefined"
@click="onNav($event, item)"
>{{ item.label }}</a>
</template>
</nav>
</div>
</header>
<div class="wrap">
<slot>
<NuxtLink
v-if="currentSlide"
class="hero"
:to="`/slides/${currentSlide.id}`"
:style="heroStyle"
:aria-label="`查看详情:${currentSlide.title}`"
>
<div class="cap">
<h3>{{ currentSlide.title }}</h3>
</div>
</NuxtLink>
<section v-else class="hero" aria-label="轮播" :style="heroStyle">
<div class="cap"><h3>{{ home.carousel.placeholder }}</h3></div>
</section>
<div v-if="variant === 'tech'" class="cols">
<ArticleColumn title="新闻动态" :region="home.news" kind="tech" />
<ArticleColumn title="通知公告" :region="home.notices" kind="tech" />
<ArticleColumn title="政策法规" :region="home.policies" kind="tech" />
</div>
<div v-else class="sheet">
<ArticleColumn title="新闻动态" :region="home.news" kind="brand" :cover="true" />
<ArticleColumn title="通知公告" :region="home.notices" kind="brand" />
<ArticleColumn title="政策法规" :region="home.policies" kind="brand" />
<ArticleColumn title="数据看板" :region="home.dashboard" kind="brand" />
</div>
<section v-if="variant === 'tech'" class="service-row">
<section class="board panel" aria-label="数据看板">
<h3 class="section-title with-mark"><TitleMark name="数据看板" />数据看板</h3>
<NuxtLink
v-if="dashboardCover"
class="board-art"
:style="{ backgroundImage: `url(${dashboardCover.imageUrl})` }"
:to="`/articles/${dashboardCover.id}`"
>
<strong>{{ dashboardCover.title }}</strong>
</NuxtLink>
<p v-if="!dashboardCover && home.dashboard.placeholder">{{ home.dashboard.placeholder }}</p>
<VerticalList
v-else-if="home.dashboard.items?.length"
:items="home.dashboard.items"
:interval-seconds="home.dashboard.intervalSeconds"
/>
</section>
<div class="side">
<AudienceBlock :home="home" />
<HotLinks :home="home" />
</div>
</section>
<section v-else class="gate">
<HotLinks :home="home" />
<AudienceBlock :home="home" />
</section>
<h3 v-if="variant === 'brand'" class="rail with-mark"><TitleMark name="协会荣誉" />协会荣誉</h3>
<section class="marquee" aria-label="荣誉滚动">
<p v-if="home.honors.placeholder" class="list" style="padding: 14px">{{ home.honors.placeholder }}</p>
<div v-else class="track" :style="trackStyle(home.honors.intervalSeconds)">
<div
v-for="(item, index) in doubled(home.honors.items)"
:key="`h${index}`"
class="honor"
:style="{ backgroundImage: `url(${item.imageUrl})` }"
/>
</div>
</section>
<h3 v-if="variant === 'brand'" class="rail with-mark"><TitleMark name="会员单位" />会员单位</h3>
<section class="marquee" aria-label="会员单位">
<p v-if="home.members.placeholder" class="list" style="padding: 14px">{{ home.members.placeholder }}</p>
<div v-else class="track" :style="trackStyle(home.members.intervalSeconds)">
<component
:is="item.navigation === 'newWindow' ? 'a' : 'span'"
v-for="(item, index) in doubled(home.members.items)"
:key="`m${index}`"
class="member"
:href="item.navigation === 'newWindow' ? item.url : undefined"
target="_blank"
rel="noopener"
>
<i :style="{ backgroundImage: `url(${item.imageUrl})` }" />
{{ item.name }}
</component>
</div>
</section>
</slot>
</div>
<footer class="site">
<div class="wrap foot">
<div>
<h3 class="with-mark"><TitleMark name="联系方式" />联系方式</h3>
<p>{{ home.contact.address }}</p>
<p>{{ home.contact.phone }}</p>
<p>{{ home.contact.email }}</p>
<div v-if="mapReady" ref="mapEl" class="map" />
<div v-else class="map">{{ home.contact.map }}</div>
</div>
<div>
<h3 class="with-mark"><TitleMark name="相关链接" />相关链接</h3>
<template v-for="name in relatedNames" :key="name">
<p>{{ name }}</p>
<p v-if="home.relatedLinks[name].placeholder">{{ home.relatedLinks[name].placeholder }}</p>
<a
v-for="link in home.relatedLinks[name].links || []"
:key="link.label"
:href="link.navigation === 'none' ? undefined : link.url"
:target="link.navigation === 'newWindow' ? '_blank' : undefined"
rel="noopener"
>{{ link.label }}</a>
</template>
</div>
<div>
<h3 class="with-mark"><TitleMark name="合规链接" />合规链接</h3>
<p v-if="home.compliance.placeholder">{{ home.compliance.placeholder }}</p>
<template v-for="link in home.compliance.links || []" :key="link.label">
<NuxtLink
v-if="link.navigation === 'sameWindow' && isInternal(link.url)"
:to="link.url"
>{{ link.label }}</NuxtLink>
<a
v-else
:href="link.navigation === 'none' ? undefined : link.url"
:target="link.navigation === 'newWindow' ? '_blank' : undefined"
rel="noopener"
>{{ link.label }}</a>
</template>
</div>
</div>
<div class="wrap icp">{{ home.icpText }} {{ home.policeText }}</div>
</footer>
</div>
</template>
<script setup lang="ts">
import TitleMark from "./TitleMark.vue"
import "~/assets/home.css"
const props = defineProps<{ home: any; logoUrl?: string }>()
const route = useRoute()
const variant = computed(() => (props.home?.templateName === "LOGO蓝" ? "brand" : "tech"))
const logoStyle = computed(() => ({ "--logo": props.logoUrl ? `url("${props.logoUrl}")` : "none" }))
const relatedNames = ["主管部门", "相关部门", "合作协会"]
const slideIndex = ref(0)
const slides = computed(() => props.home?.carousel?.items || [])
const currentSlide = computed(() => slides.value[slideIndex.value] || null)
const heroStyle = computed(() =>
currentSlide.value?.imageUrl ? { backgroundImage: `url(${currentSlide.value.imageUrl})` } : {},
)
const dashboardCover = computed(() => (props.home?.dashboard?.items || []).find((item: any) => item.imageUrl))
const mapReady = computed(() => props.home?.contact?.map && typeof props.home.contact.map === "object")
const mapEl = ref<HTMLElement | null>(null)
const topMenu = computed(() => {
const raw = props.home?.shortcutMenu?.items || []
const homeItem = raw.find((item: any) => item.url === "/") || {
label: "首页",
url: "/",
navigation: "sameWindow",
}
const shortcuts = raw.filter((item: any) => item.url !== "/")
return [
homeItem,
{ label: "组织概况", url: "/about", navigation: "sameWindow" },
{ label: "继续教育", url: "/edu", navigation: "sameWindow" },
...shortcuts,
{ label: "资质荣誉", url: "/honors", navigation: "sameWindow" },
{ label: "机构名录", url: "/institutions", navigation: "sameWindow" },
{ label: "行业党建", url: "/party", navigation: "sameWindow" },
{ label: "专家服务", url: "/expert", navigation: "sameWindow" },
{ label: "技术交流", url: "/tech", navigation: "sameWindow" },
{ label: "资料库", url: "/library", navigation: "sameWindow" },
{ label: "报告核验", url: "/reports", navigation: "sameWindow" },
{ label: "行业自律", url: "/discipline", navigation: "sameWindow" },
{ label: "联系合作", url: "/contact", navigation: "sameWindow" },
]
})
function isInternal(url: string) {
return typeof url === "string" && url.startsWith("/")
}
function isCurrentNav(url: string) {
const path = route.path
if (url === "/") return path === "/"
if (url === "/about") return path === "/about" || path.startsWith("/about/")
if (url === "/edu") return path === "/edu" || path.startsWith("/edu/")
if (url === "/party") return path === "/party" || path.startsWith("/party/")
if (url === "/expert") return path === "/expert" || path.startsWith("/expert/")
if (url === "/tech") return path === "/tech" || path.startsWith("/tech/")
if (url === "/library") return path === "/library" || path.startsWith("/library/")
if (url === "/reports") return path === "/reports" || path.startsWith("/reports/")
if (url === "/discipline") return path === "/discipline" || path.startsWith("/discipline/")
return path === url || path.startsWith(`${url}/`)
}
function doubled(items: any[]) {
return [...items, ...items]
}
function trackStyle(seconds?: number) {
return { animationDuration: `${Math.max(seconds || 8, 1) * 4}s` }
}
function onNav(event: MouseEvent, item: any) {
if (item.navigation === "none") event.preventDefault()
}
let timer: ReturnType<typeof setInterval> | undefined
function arm() {
if (!import.meta.client) return
if (timer) clearInterval(timer)
const current = currentSlide.value
if (!current || slides.value.length < 2) return
timer = setInterval(() => {
slideIndex.value = (slideIndex.value + 1) % slides.value.length
arm()
}, Math.max(current.intervalSeconds || 6, 1) * 1000)
}
watch(slides, () => {
slideIndex.value = 0
arm()
})
onMounted(() => {
arm()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
async function mountMap() {
const map = props.home?.contact?.map
if (!map || typeof map !== "object" || !mapEl.value) return
const w = window as any
w._AMapSecurityConfig = { securityJsCode: map.securityJsCode }
await new Promise<void>((resolve, reject) => {
if (w.AMap) {
resolve()
return
}
const script = document.createElement("script")
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(map.key)}`
script.onload = () => resolve()
script.onerror = () => reject(new Error("map"))
document.head.appendChild(script)
})
if (!mapEl.value) return
const amap = new w.AMap.Map(mapEl.value, {
zoom: 16,
center: [map.longitude, map.latitude],
})
new w.AMap.Marker({ position: [map.longitude, map.latitude], map: amap })
}
onMounted(mountMap)
watch(mapReady, (ready) => {
if (ready) nextTick(mountMap)
})
</script>
+63
View File
@@ -0,0 +1,63 @@
<template>
<svg class="title-mark" viewBox="0 0 24 24" aria-hidden="true">
<g v-if="name === '新闻动态'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
<path d="M5 4.5h11.5A1.5 1.5 0 0 1 18 6v13.2" />
<path d="M7.2 19.5h11.3a1.5 1.5 0 0 0 1.5-1.5V7.2" />
<path d="M8 8.2h7M8 11.4h7M8 14.6h4.2" stroke-linecap="round" />
</g>
<g v-else-if="name === '通知公告'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
<path d="M5.5 10.2v3.2a1.6 1.6 0 0 0 1.6 1.6h1.1l1.2 2.4a.8.8 0 0 0 1.4-.4V6.8a.8.8 0 0 0-1.3-.6L8.2 8.6H7.1a1.6 1.6 0 0 0-1.6 1.6Z" />
<path d="M14.2 9.2a3.2 3.2 0 0 1 0 5.2M16.4 7.2a6 6 0 0 1 0 9.2" stroke-linecap="round" />
</g>
<g v-else-if="name === '政策法规'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
<path d="M7 3.8h7.2L19 8.4V20a1.2 1.2 0 0 1-1.2 1.2H7A1.2 1.2 0 0 1 5.8 20V5A1.2 1.2 0 0 1 7 3.8Z" />
<path d="M14 3.8V8.2h4.6" />
<path d="M8.4 12.2h6.4M8.4 15.2h4.2" stroke-linecap="round" />
<circle cx="15.6" cy="16.2" r="1.7" stroke-width="1.4" />
</g>
<g v-else-if="name === '数据看板'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M4.5 19.5h15" />
<path d="M7 19.5V12.2M12 19.5V8.2M17 19.5V5.5" />
</g>
<g v-else-if="name === '估价师'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<circle cx="10" cy="8" r="2.6" />
<path d="M5.2 18.2c.6-2.6 2.5-3.8 4.8-3.8s4.2 1.2 4.8 3.8" />
<path d="M16.2 10.2h3.2v2.1h-3.2z" stroke-width="1.5" stroke-linejoin="round" />
<path d="M17.8 8.6v1.6M17.8 12.3v1.4" stroke-width="1.5" />
</g>
<g v-else-if="name === '估价机构'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M4.5 19.5h15M6.2 19.5V9.2L12 5.2l5.8 4V19.5" />
<path d="M10 19.5v-4.2h4V19.5M9.2 11.2h1.6M13.2 11.2h1.6M9.2 14.2h1.6M13.2 14.2h1.6" />
</g>
<g v-else-if="name === '公众'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<circle cx="8" cy="8.2" r="2.1" />
<circle cx="15.6" cy="8.6" r="1.8" />
<path d="M3.8 17.8c.5-2.2 2.1-3.3 4.2-3.3s3.7 1.1 4.2 3.3" />
<path d="M13.2 14.8c1.2-.4 2.3-.3 3.4.4 1 .7 1.6 1.6 1.9 2.6" />
</g>
<g v-else-if="name === '协会荣誉'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
<circle cx="12" cy="9" r="4.2" />
<path d="M12 13.2 10.2 19l1.8-1 1.8 1-1.8-5.8" />
<path d="M10.2 9.1 12 7.6l1.8 1.5-.4 2.1H10.6l-.4-2.1Z" stroke-width="1.3" />
</g>
<g v-else-if="name === '会员单位'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M3.8 19.5h16.4M5 19.5V10.2h5.2V19.5M13.2 19.5V7.2H19V19.5" />
<path d="M6.6 12.6h1.8M6.6 15.2h1.8M15 9.8h1.8M15 12.6h1.8M15 15.2h1.8" stroke-width="1.5" />
</g>
<g v-else-if="name === '联系方式'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
<path d="M7.2 4.8h2.2l1.2 3-1.6 1a9.2 9.2 0 0 0 4.2 4.2l1-1.6 3 1.2v2.2a1.4 1.4 0 0 1-1.5 1.4A12.2 12.2 0 0 1 5.8 6.3a1.4 1.4 0 0 1 1.4-1.5Z" />
</g>
<g v-else-if="name === '相关链接'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M10 13.2a4 4 0 0 0 5.6.2l2.2-2.2a4 4 0 0 0-5.6-5.6L11 6.8" />
<path d="M14 10.8a4 4 0 0 0-5.6-.2L6.2 12.8a4 4 0 0 0 5.6 5.6L13 17.2" />
</g>
<g v-else-if="name === '合规链接'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M12 3.8 19 6.4v5.4c0 4-2.8 6.6-7 8.4-4.2-1.8-7-4.4-7-8.4V6.4L12 3.8Z" />
<path d="M9.2 12.1 11 13.9l3.8-3.8" />
</g>
</svg>
</template>
<script setup lang="ts">
defineProps<{ name: string }>()
</script>
@@ -0,0 +1,85 @@
<template>
<div
class="vscroll"
:style="viewportHeight ? { height: `${viewportHeight}px` } : undefined"
>
<ul ref="listEl" class="list" :class="{ vtrack: scrolling }" :style="trackStyle">
<li v-for="item in items" :key="item.id">
<NuxtLink :to="`/articles/${item.id}`">{{ item.title }}</NuxtLink>
</li>
<template v-if="scrolling">
<li v-for="item in items" :key="`loop-${item.id}`" aria-hidden="true">
<NuxtLink :to="`/articles/${item.id}`" tabindex="-1">{{ item.title }}</NuxtLink>
</li>
</template>
</ul>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ items: any[]; intervalSeconds?: number }>()
const listEl = ref<HTMLElement | null>(null)
const viewportHeight = ref(0)
const scrolling = ref(false)
const reduced = ref(false)
let lastWidth = 0
let observer: ResizeObserver | undefined
const trackStyle = computed(() => {
if (!scrolling.value) return undefined
const seconds = Math.max(props.items.length * Math.max(props.intervalSeconds || 3, 1), 8)
return { animationDuration: `${seconds}s` }
})
function measure() {
const list = listEl.value
if (!list || reduced.value) {
scrolling.value = false
viewportHeight.value = 0
return
}
const rows = Array.from(list.children) as HTMLElement[]
const count = props.items.length
if (count <= 1 || rows.length < count) {
scrolling.value = false
viewportHeight.value = 0
return
}
const visible = Math.min(4, count - 1)
let windowHeight = 0
let fullHeight = 0
for (let index = 0; index < count; index += 1) {
const height = rows[index]?.offsetHeight || 0
if (index < visible) windowHeight += height
fullHeight += height
}
if (fullHeight <= windowHeight + 1) {
scrolling.value = false
viewportHeight.value = 0
return
}
viewportHeight.value = windowHeight
scrolling.value = true
}
onMounted(() => {
reduced.value = window.matchMedia("(prefers-reduced-motion: reduce)").matches
nextTick(() => {
measure()
const list = listEl.value
if (!list || typeof ResizeObserver === "undefined") return
observer = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width || 0)
if (width === lastWidth) return
lastWidth = width
measure()
})
observer.observe(list)
})
})
watch(() => props.items, () => nextTick(measure))
onBeforeUnmount(() => observer?.disconnect())
</script>
+7
View File
@@ -0,0 +1,7 @@
export const ABOUT_SECTIONS = [
{ key: "intro", label: "协会简介", path: "/about/intro", pageKey: "intro" },
{ key: "org", label: "组织机构", path: "/about/org", pageKey: "org" },
{ key: "experts", label: "专家委员会", path: "/about/experts" },
{ key: "reports", label: "年度报告/工作计划", path: "/about/reports", category: "annual_reports" },
{ key: "briefs", label: "工作简报", path: "/about/briefs", category: "work_briefs" },
] as const
@@ -0,0 +1,6 @@
export const DISCIPLINE_SECTIONS = [
{ key: "complaints", label: "投诉举报", path: "/discipline/complaints" },
{ key: "warnings", label: "警示案例", path: "/discipline/warnings", category: "warning_cases" },
{ key: "integrity", label: "诚信档案", path: "/discipline/integrity", category: "integrity_records" },
{ key: "results", label: "结果公示", path: "/discipline/results", category: "discipline_results" },
] as const
+4
View File
@@ -0,0 +1,4 @@
export const EDU_SECTIONS = [
{ key: "platforms", label: "在线教育", path: "/edu/platforms" },
{ key: "notices", label: "教育公告", path: "/edu/notices" },
] as const
+13
View File
@@ -0,0 +1,13 @@
export const EXPERT_SECTIONS = [
{ key: "assistant", label: "智能估价", path: "/expert/assistant" },
{ key: "guides", label: "专项估价服务", path: "/expert/guides" },
{ key: "qa", label: "专家答疑", path: "/expert/qa" },
] as const
export const APPRAISAL_KINDS = [
{ key: "all", label: "全部" },
{ key: "residential", label: "住宅" },
{ key: "commercial", label: "商业" },
{ key: "industrial", label: "工业" },
{ key: "specialty", label: "特色地产" },
] as const
+13
View File
@@ -0,0 +1,13 @@
export const LIBRARY_SECTIONS = [
{ key: "policies", label: "政策分析", path: "/library/policies" },
{ key: "cases", label: "案例库", path: "/library/cases" },
] as const
export const POLICY_KINDS = [
{ key: "all", label: "全部" },
{ key: "national", label: "国家级" },
{ key: "local", label: "地方级" },
{ key: "industry", label: "行业级" },
{ key: "expert", label: "专家解读" },
{ key: "digest", label: "政策汇编" },
] as const
+76
View File
@@ -0,0 +1,76 @@
const TOKEN_KEY = "gsfdc_member_token"
export function useMemberAuth() {
const token = useState<string | null>("member-token", () => null)
const email = useState<string | null>("member-email", () => null)
const ready = useState("member-auth-ready", () => false)
const { get } = useSiteApi()
function readStored() {
if (!import.meta.client) return null
return window.localStorage.getItem(TOKEN_KEY)
}
function persist(next: string | null) {
if (!import.meta.client) return
if (next) window.localStorage.setItem(TOKEN_KEY, next)
else window.localStorage.removeItem(TOKEN_KEY)
}
async function refreshProfile() {
if (!token.value) {
email.value = null
return null
}
try {
const me = await get<{ id: number; email: string }>("/member/me", token.value)
email.value = me.email
return me
} catch (exc: any) {
const status = Number(exc?.statusCode || exc?.response?.status || 0)
// 仅令牌失效时清会话;网络抖动不要把本地登录清掉。
if (status === 401 || status === 403) {
token.value = null
email.value = null
persist(null)
}
return null
}
}
async function ensure() {
// localStorage 只在客户端可用;SSR 若把 ready 标真,刷新后会跳过恢复导致“一刷新就掉线”。
if (!import.meta.client) return
if (ready.value) return
const stored = readStored()
token.value = stored
if (stored) await refreshProfile()
ready.value = true
}
async function setSession(accessToken: string) {
token.value = accessToken
persist(accessToken)
ready.value = true
await refreshProfile()
}
function clearSession() {
token.value = null
email.value = null
persist(null)
}
const isAuthed = computed(() => Boolean(token.value))
return {
token,
email,
ready,
isAuthed,
ensure,
setSession,
clearSession,
refreshProfile,
}
}
+4
View File
@@ -0,0 +1,4 @@
export const PARTY_SECTIONS = [
{ key: "branch", label: "支部介绍", path: "/party/branch" },
{ key: "activities", label: "行业党建", path: "/party/activities", category: "party_activities" },
] as const
+4
View File
@@ -0,0 +1,4 @@
export const REPORT_SECTIONS = [
{ key: "query", label: "报告查询", path: "/reports/query" },
{ key: "review", label: "报告审核", path: "/reports/review" },
] as const
+34
View File
@@ -0,0 +1,34 @@
type FetchOpts = {
method?: "GET" | "POST" | "PUT" | "DELETE"
body?: any
token?: string | null
formData?: FormData
}
export function useSiteApi() {
const config = useRuntimeConfig()
async function request<T>(path: string, opts: FetchOpts = {}) {
const headers: Record<string, string> = {}
if (opts.token) headers.Authorization = `Bearer ${opts.token}`
return $fetch<T>(`${config.public.apiBase}${path}`, {
method: opts.method || (opts.body || opts.formData ? "POST" : "GET"),
body: opts.formData || opts.body,
headers,
})
}
function get<T>(path: string, token?: string | null) {
return request<T>(path, { method: "GET", token })
}
function post<T>(path: string, body?: any, token?: string | null) {
return request<T>(path, { method: "POST", body, token })
}
function upload<T>(path: string, formData: FormData, token?: string | null) {
return request<T>(path, { method: "POST", formData, token })
}
return { get, post, upload }
}
+47
View File
@@ -0,0 +1,47 @@
/** 门户页头页脚配置:跨路由复用,避免每次点击整页白屏重拉。 */
export function useSiteChrome() {
const { get } = useSiteApi()
const home = useState<any>("site-chrome-home", () => null)
const logoUrl = useState<string>("site-chrome-logo", () => "")
const loading = useState<boolean>("site-chrome-loading", () => false)
async function ensureChrome() {
if (home.value) return home.value
if (loading.value) {
await until(home).toBeTruthy()
return home.value
}
loading.value = true
try {
const [page, settings] = await Promise.all([
get<any>("/home"),
get<any>("/settings").catch(() => ({})),
])
home.value = page
logoUrl.value = settings.logoUrl || ""
return page
} finally {
loading.value = false
}
}
return { home, logoUrl, loading, ensureChrome }
}
function until(source: Ref<any>) {
return {
toBeTruthy: () =>
new Promise<void>((resolve) => {
if (source.value) {
resolve()
return
}
const stop = watch(source, (value) => {
if (value) {
stop()
resolve()
}
})
}),
}
}
+4
View File
@@ -0,0 +1,4 @@
export const TECH_SECTIONS = [
{ key: "seminars", label: "技术研讨", path: "/tech/seminars", category: "tech_seminars" },
{ key: "exchange", label: "技术交流", path: "/tech/exchange", category: "tech_exchange" },
] as const
+49
View File
@@ -0,0 +1,49 @@
<template>
<div class="shell" data-theme="formal">
<header class="header">
<p class="mark">甘肃省房地产价格评估行业协会</p>
</header>
<main>
<slot />
</main>
<footer class="footer">
<p>陇ICP备19001227号</p>
<p>甘公网安备62010202001984号</p>
</footer>
</div>
</template>
<style scoped>
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header,
.footer {
background: var(--color-primary);
color: #fff;
padding: 20px 8vw;
}
.mark {
margin: 0;
letter-spacing: 0.08em;
}
main {
flex: 1;
padding: 48px 8vw;
}
.footer {
display: flex;
gap: 24px;
font-size: 14px;
}
.footer p {
margin: 0;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout">
<nav class="side-nav" aria-label="组织概况子导航">
<NuxtLink
v-for="section in ABOUT_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel">
<NuxtPage />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { ABOUT_SECTIONS } from "~/composables/useAboutNav"
definePageMeta({ layout: false })
const route = useRoute()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => {
const path = route.path
if (path.startsWith("/about/org")) return "org"
if (path.startsWith("/about/experts")) return "experts"
if (path.startsWith("/about/reports")) return "reports"
if (path.startsWith("/about/briefs")) return "briefs"
return "intro"
})
const crumb = computed(() => {
const section = ABOUT_SECTIONS.find((item) => item.key === activeKey.value)
return `组织概况 / ${section?.label || "协会简介"}`
})
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
+6
View File
@@ -0,0 +1,6 @@
<template>
<AboutArticleList title="工作简报" category="work_briefs" />
</template>
<script setup lang="ts">
</script>
+44
View File
@@ -0,0 +1,44 @@
<template>
<div>
<NuxtLink class="back" to="/about/experts">← 返回列表</NuxtLink>
<p v-if="loading" class="hint">正文加载中…</p>
<template v-else-if="detail">
<h2 class="page-title">{{ detail.name }}</h2>
<div class="prose" v-html="detail.bodyHtml" />
<AboutAttachments :files="detail.attachments" />
</template>
<p v-else class="placeholder">内容不存在或尚未发布</p>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { get } = useSiteApi()
const detail = ref<any>(null)
const loading = ref(true)
async function load() {
loading.value = true
detail.value = null
try {
detail.value = await get<any>(`/about/experts/${route.params.id}`).catch(() => null)
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => route.params.id, load)
</script>
<style scoped>
.back { display: inline-block; margin-bottom: 12px; font-size: 13px; color: var(--c, #1677ff); text-decoration: none; }
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint, .placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
}
.placeholder { border: 1px dashed var(--line, #d6e4ff); }
.prose { font-size: 15px; line-height: 1.85; }
.prose :deep(p) { margin: 0 0 1em; }
</style>
+43
View File
@@ -0,0 +1,43 @@
<template>
<div>
<h2 class="page-title">专家委员会</h2>
<p v-if="loading" class="hint">列表加载中…</p>
<p v-else-if="region?.placeholder" class="placeholder">{{ region.placeholder }}</p>
<ul v-else-if="region?.items?.length" class="list">
<li v-for="item in region.items" :key="item.id">
<NuxtLink :to="`/about/experts/${item.id}`">{{ item.name }}</NuxtLink>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const { get } = useSiteApi()
const region = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try {
region.value = await get<any>("/about/experts")
} finally {
loading.value = false
}
})
</script>
<style scoped>
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint, .placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
}
.placeholder { border: 1px dashed var(--line, #d6e4ff); }
.list { margin: 0; padding: 0; list-style: none; }
.list li {
padding: 12px 0;
border-bottom: 1px solid var(--line, #d6e4ff);
font-size: 14px;
}
.list a { color: inherit; text-decoration: none; }
.list a:hover { color: var(--c, #1677ff); }
</style>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts">
await navigateTo("/about/intro", { replace: true })
</script>
+36
View File
@@ -0,0 +1,36 @@
<template>
<div>
<h2 class="page-title">协会简介</h2>
<p v-if="loading" class="hint">正文加载中…</p>
<p v-else-if="page?.placeholder" class="placeholder">{{ page.placeholder }}</p>
<template v-else-if="page">
<div class="prose" v-html="page.bodyHtml" />
<AboutAttachments :files="page.attachments" />
</template>
</div>
</template>
<script setup lang="ts">
const { get } = useSiteApi()
const page = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try {
page.value = await get<any>("/about/pages/intro")
} finally {
loading.value = false
}
})
</script>
<style scoped>
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint, .placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
}
.placeholder { border: 1px dashed var(--line, #d6e4ff); }
.prose { font-size: 15px; line-height: 1.85; }
.prose :deep(p) { margin: 0 0 1em; }
</style>
+36
View File
@@ -0,0 +1,36 @@
<template>
<div>
<h2 class="page-title">组织机构</h2>
<p v-if="loading" class="hint">正文加载中…</p>
<p v-else-if="page?.placeholder" class="placeholder">{{ page.placeholder }}</p>
<template v-else-if="page">
<div class="prose" v-html="page.bodyHtml" />
<AboutAttachments :files="page.attachments" />
</template>
</div>
</template>
<script setup lang="ts">
const { get } = useSiteApi()
const page = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try {
page.value = await get<any>("/about/pages/org")
} finally {
loading.value = false
}
})
</script>
<style scoped>
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint, .placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
}
.placeholder { border: 1px dashed var(--line, #d6e4ff); }
.prose { font-size: 15px; line-height: 1.85; }
.prose :deep(p) { margin: 0 0 1em; }
</style>
+6
View File
@@ -0,0 +1,6 @@
<template>
<AboutArticleList title="年度报告/工作计划" category="annual_reports" />
</template>
<script setup lang="ts">
</script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p v-if="loading" class="panel detail">正文加载中…</p>
<article v-else-if="detail" class="panel detail">
<h2>{{ detail.title }}</h2>
<p v-if="detail.kindLabel" class="kind">{{ detail.kindLabel }}</p>
<div
v-if="detail.imageUrl"
class="cover"
:style="{ backgroundImage: `url(${detail.imageUrl})` }"
role="img"
aria-label="封面"
/>
<div v-html="detail.bodyHtml" />
<section v-if="detail.attachments?.length" class="files">
<h3>附件下载</h3>
<ul>
<li v-for="file in detail.attachments" :key="file.url">
<a :href="file.url" :download="file.filename" rel="noopener">{{ file.filename }}</a>
</li>
</ul>
</section>
</article>
<p v-else class="panel detail">内容不存在或尚未发布</p>
</SiteHome>
</template>
<script setup lang="ts">
definePageMeta({ layout: false })
const route = useRoute()
const { get } = useSiteApi()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const detail = ref<any>(null)
const loading = ref(true)
let ticket = 0
async function load() {
const current = ++ticket
loading.value = true
detail.value = null
await ensureChrome()
try {
const article = await get<any>(`/home/articles/${route.params.id}`).catch(() => null)
if (current !== ticket) return
detail.value = article
} finally {
if (current === ticket) loading.value = false
}
}
onMounted(load)
watch(() => route.params.id, (id, previous) => {
if (previous !== undefined && id !== previous) load()
})
</script>
<style scoped>
.detail {
margin-top: 24px;
padding: 24px;
}
h2 {
margin: 0 0 16px;
}
.kind {
margin: -8px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.cover {
width: 100%;
height: 220px;
margin: 0 0 20px;
border-radius: 10px;
background: var(--chip, #f7faff) center/cover no-repeat;
border: 1px solid var(--line, #e5e7eb);
}
.files {
margin-top: 28px;
padding-top: 16px;
border-top: 1px solid var(--line, #e5e7eb);
}
.files h3 {
margin: 0 0 10px;
font-size: 16px;
}
.files ul {
margin: 0;
padding: 0;
list-style: none;
}
.files li + li {
margin-top: 8px;
}
.files a {
color: var(--c, #1677ff);
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / 联系合作</p>
<div class="main-panel">
<h2 class="page-title">联系合作</h2>
<p v-if="loading" class="hint">正文加载中…</p>
<p v-else-if="page?.placeholder" class="placeholder">{{ page.placeholder }}</p>
<template v-else-if="page">
<div class="prose" v-html="page.bodyHtml" />
<AboutAttachments :files="page.attachments" />
</template>
</div>
</SiteHome>
</template>
<script setup lang="ts">
definePageMeta({ layout: false })
const { get } = useSiteApi()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const page = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
await ensureChrome()
try {
page.value = await get<any>("/about/pages/contact")
} finally {
loading.value = false
}
})
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
margin: 0 0 40px;
}
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint, .placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
}
.placeholder { border: 1px dashed var(--line, #d6e4ff); }
.prose { font-size: 15px; line-height: 1.85; }
.prose :deep(p) { margin: 0 0 1em; }
</style>
+106
View File
@@ -0,0 +1,106 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout">
<nav class="side-nav" aria-label="行业自律">
<NuxtLink
v-for="section in DISCIPLINE_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel">
<NuxtPage />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { DISCIPLINE_SECTIONS } from "~/composables/useDisciplineNav"
definePageMeta({ layout: false })
const route = useRoute()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => {
const path = route.path
if (path.startsWith("/discipline/warnings")) return "warnings"
if (path.startsWith("/discipline/integrity")) return "integrity"
if (path.startsWith("/discipline/results")) return "results"
return "complaints"
})
const crumb = computed(() => {
const section = DISCIPLINE_SECTIONS.find((item) => item.key === activeKey.value)
return `行业自律 / ${section?.label || "投诉举报"}`
})
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
+281
View File
@@ -0,0 +1,281 @@
<template>
<section aria-label="投诉举报">
<div class="head">
<h2>投诉举报</h2>
<button type="button" class="text-btn" @click="openNotice">投诉说明</button>
</div>
<p class="muted">举报标题、被举报对象、违规事实、联系人、联系电话和证据材料为必填。证据材料仅供协会核查,不对外公开。</p>
<form class="report" @submit.prevent="submit">
<label for="complaint-title">举报标题 <span class="req">必填</span></label>
<input id="complaint-title" v-model="form.title" maxlength="200" autocomplete="off">
<label for="complaint-subject">被举报对象 <span class="req">必填</span></label>
<input id="complaint-subject" v-model="form.subject" maxlength="200" placeholder="机构或个人" autocomplete="off">
<label for="complaint-body">违规事实 <span class="req">必填</span></label>
<textarea id="complaint-body" v-model="form.body" maxlength="4000" placeholder="写明时间、行为和与估价执业相关的问题" />
<label for="complaint-name">联系人 <span class="req">必填</span></label>
<input id="complaint-name" v-model="form.contactName" maxlength="40" autocomplete="name">
<label for="complaint-mobile">联系电话 <span class="req">必填</span></label>
<input id="complaint-mobile" v-model="form.mobile" type="tel" maxlength="20" inputmode="numeric" autocomplete="tel" placeholder="11 位手机号">
<label for="complaint-email">联系邮箱</label>
<input id="complaint-email" v-model="form.email" type="email" maxlength="120" autocomplete="email">
<label for="complaint-files">证据材料 <span class="req">必填</span></label>
<input id="complaint-files" type="file" multiple @change="onFiles">
<ul v-if="files.length" class="files">
<li v-for="(file, index) in files" :key="`${file.name}-${index}`">
<span>{{ file.name }}</span>
<button type="button" class="link" @click="removeFile(index)">移除</button>
</li>
</ul>
<div class="actions">
<button type="submit" class="btn" :disabled="submitting">提交举报</button>
</div>
</form>
<p v-if="formError" class="error">{{ formError }}</p>
<p v-if="submitted" class="receipt" role="status">举报已提交。</p>
<Teleport to="body">
<div v-if="noticeOpen" class="mask" @click.self="closeNotice">
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="complaint-notice-title">
<div class="dialog-head">
<h3 id="complaint-notice-title">投诉说明</h3>
<button type="button" class="text-btn" @click="closeNotice">关闭</button>
</div>
<p v-if="noticeLoading" class="muted">说明加载中…</p>
<p v-else-if="noticeError" class="error">{{ noticeError }}</p>
<template v-else-if="notice">
<h4>举报渠道</h4>
<p v-if="!notice.phone && !notice.email" class="muted">举报电话与专用邮箱整理中</p>
<ul v-else class="channels">
<li v-if="notice.phone">
举报电话
<a :href="`tel:${dialPhone}`">{{ notice.phone }}</a>
</li>
<li v-if="notice.email">
专用邮箱
<a :href="`mailto:${notice.email}`">{{ notice.email }}</a>
</li>
</ul>
<h4>受理范围</h4>
<div v-if="notice.scopeHtml" class="rich" v-html="notice.scopeHtml" />
<p v-else class="muted">{{ notice.scopePlaceholder }}</p>
<h4>处理流程</h4>
<p v-if="notice.workdays > 0" class="promise">承诺在 {{ notice.workdays }} 个工作日内完成受理、核查、处理并反馈。</p>
<p v-else class="muted">处理时限尚未配置。</p>
<div v-if="notice.processHtml" class="rich" v-html="notice.processHtml" />
<p v-else class="muted">{{ notice.processPlaceholder }}</p>
</template>
</div>
</div>
</Teleport>
</section>
</template>
<script setup lang="ts">
type Notice = {
phone: string
email: string
scopeHtml: string
processHtml: string
scopePlaceholder: string
processPlaceholder: string
workdays: number
}
const { get, upload } = useSiteApi()
const notice = ref<Notice | null>(null)
const noticeOpen = ref(false)
const noticeLoading = ref(false)
const noticeError = ref("")
const dialPhone = computed(() => (notice.value?.phone || "").replace(/[\s-]/g, ""))
const files = ref<File[]>([])
const submitting = ref(false)
const formError = ref("")
const submitted = ref(false)
const form = reactive({
title: "",
subject: "",
body: "",
contactName: "",
mobile: "",
email: "",
})
function onNoticeKey(event: KeyboardEvent) {
if (event.key === "Escape") closeNotice()
}
function closeNotice() {
noticeOpen.value = false
window.removeEventListener("keydown", onNoticeKey)
}
async function openNotice() {
noticeOpen.value = true
window.addEventListener("keydown", onNoticeKey)
if (notice.value || noticeLoading.value) return
noticeLoading.value = true
noticeError.value = ""
try {
notice.value = await get<Notice>("/discipline/notice")
} catch {
noticeError.value = "说明加载失败"
} finally {
noticeLoading.value = false
}
}
onBeforeUnmount(() => {
if (import.meta.client) window.removeEventListener("keydown", onNoticeKey)
})
function errorText(exc: any, fallback: string) {
const detail = exc?.data?.detail
if (typeof detail === "string" && detail.trim()) return detail
return fallback
}
function mobileOk(value: string) {
return /^1[3-9]\d{9}$/.test(value.replace(/[\s-]/g, ""))
}
function onFiles(event: Event) {
const input = event.target as HTMLInputElement
const picked = [...(input.files || [])]
input.value = ""
files.value = [...files.value, ...picked].slice(0, 8)
}
function removeFile(index: number) {
files.value = files.value.filter((_, item) => item !== index)
}
async function submit() {
formError.value = ""
submitted.value = false
if (!form.title.trim() || !form.subject.trim() || !form.body.trim() || !form.contactName.trim()) {
formError.value = "请完整填写必填项"
return
}
if (!mobileOk(form.mobile)) {
formError.value = "请填写有效的联系电话"
return
}
if (form.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
formError.value = "联系邮箱格式不正确"
return
}
if (!files.value.length) {
formError.value = "请上传证据材料"
return
}
submitting.value = true
try {
const data = new FormData()
data.append("title", form.title.trim())
data.append("subject", form.subject.trim())
data.append("body", form.body.trim())
data.append("contactName", form.contactName.trim())
data.append("mobile", form.mobile.trim())
data.append("email", form.email.trim())
for (const file of files.value) data.append("evidence", file)
await upload("/discipline/complaints", data)
form.title = ""
form.subject = ""
form.body = ""
form.contactName = ""
form.mobile = ""
form.email = ""
files.value = []
submitted.value = true
} catch (exc: any) {
formError.value = errorText(exc, "提交失败,请稍后再试")
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 0 0 8px;
}
h2 { margin: 0; font-size: 22px; font-weight: 650; letter-spacing: 0.06em; }
.text-btn {
appearance: none;
border: 0;
padding: 0;
background: transparent;
color: var(--c, #1677ff);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.mask {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12vh 16px 24px;
background: rgba(11, 31, 58, 0.45);
}
.dialog {
width: min(640px, 100%);
max-height: min(70vh, 720px);
overflow: auto;
padding: 20px 22px 24px;
background: var(--surface, #fff);
border-radius: 14px;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.dialog-head h3 { margin: 0; font-size: 18px; }
h4 { margin: 16px 0 8px; font-size: 15px; }
.promise { margin: 0 0 8px; font-size: 14px; line-height: 1.7; }
.channels { margin: 0; padding: 0; list-style: none; }
.channels li { display: flex; gap: 12px; margin: 0 0 6px; font-size: 14px; }
.channels a { color: var(--c, #1677ff); }
.rich { font-size: 14px; line-height: 1.7; }
.rich :deep(p) { margin: 0 0 8px; }
.muted { margin: 0 0 8px; font-size: 14px; line-height: 1.7; color: var(--muted, #5b6b85); }
.files { margin: 8px 0 0; padding: 0; list-style: none; }
.files li { display: flex; gap: 12px; align-items: center; margin: 0 0 6px; font-size: 14px; }
label { display: block; margin: 12px 0 6px; font-size: 13px; color: var(--muted, #5b6b85); }
.req { margin-left: 6px; color: #c2410c; font-size: 12px; }
input, textarea {
width: 100%; box-sizing: border-box; padding: 10px 12px;
border: 1px solid var(--line, #d6e4ff); border-radius: 8px;
background: var(--surface, #fff); color: var(--text, #0b1f3a); font: inherit;
}
textarea { min-height: 140px; resize: vertical; }
.btn, .link {
appearance: none; border: 0; border-radius: 8px; padding: 10px 16px;
font: inherit; background: var(--c, #1677ff); color: #fff; cursor: pointer;
}
.link { padding: 0; background: transparent; color: var(--c, #1677ff); }
.actions { margin-top: 16px; display: flex; justify-content: flex-end; }
.error { margin: 10px 0 0; color: #b42318; font-size: 14px; }
.receipt {
margin: 16px 0 0; padding: 14px 16px;
border: 1px solid var(--line, #d6e4ff); border-radius: 12px;
background: var(--chip, #f7faff);
font-size: 14px; line-height: 1.7;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts">
await navigateTo("/discipline/complaints", { replace: true })
</script>
@@ -0,0 +1,3 @@
<template>
<AboutArticleList title="诚信档案" category="integrity_records" show-cover />
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<AboutArticleList title="结果公示" category="discipline_results" show-cover />
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<AboutArticleList title="警示案例" category="warning_cases" show-cover />
</template>
+108
View File
@@ -0,0 +1,108 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout">
<nav class="side-nav" aria-label="继续教育子导航">
<NuxtLink
v-for="section in EDU_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel">
<NuxtPage />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { EDU_SECTIONS } from "~/composables/useEduNav"
definePageMeta({ layout: false })
const route = useRoute()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => {
const path = route.path
if (path.startsWith("/edu/notices")) return "notices"
return "platforms"
})
const crumb = computed(() => {
const section = EDU_SECTIONS.find((item) => item.key === activeKey.value)
return `继续教育 / ${section?.label || "在线教育"}`
})
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts">
await navigateTo("/edu/platforms", { replace: true })
</script>
+3
View File
@@ -0,0 +1,3 @@
<template>
<AboutArticleList title="教育公告" category="edu_notices" show-cover />
</template>
+91
View File
@@ -0,0 +1,91 @@
<template>
<div>
<h2 class="page-title">在线教育</h2>
<p v-if="loading" class="hint">列表加载中…</p>
<p v-else-if="placeholder" class="placeholder">{{ placeholder }}</p>
<ul v-else class="cards">
<li v-for="(item, index) in items" :key="`${item.name}-${index}`">
<a :href="item.url" target="_blank" rel="noopener">
<img :src="item.imageUrl" :alt="item.name" />
<span>{{ item.name }}</span>
</a>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const { get } = useSiteApi()
const loading = ref(true)
const placeholder = ref("")
const items = ref<{ name: string; url: string; imageUrl: string }[]>([])
try {
const body = await get<any>("/edu/platforms")
placeholder.value = body?.placeholder || ""
items.value = body?.items || []
} finally {
loading.value = false
}
</script>
<style scoped>
.page-title {
margin: 0 0 18px;
font-size: 22px;
font-weight: 650;
letter-spacing: 0.08em;
}
.placeholder {
margin: 0;
padding: 48px 20px;
text-align: center;
color: var(--muted, #5b6b85);
font-size: 15px;
border: 1px dashed var(--line, #d6e4ff);
}
.hint {
margin: 12px 0 0;
text-align: center;
color: var(--muted, #5b6b85);
font-size: 14px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.cards a {
display: flex;
flex-direction: column;
height: 100%;
color: inherit;
text-decoration: none;
border: 1px solid var(--line, #d6e4ff);
border-radius: 12px;
overflow: hidden;
background: var(--chip, #f7faff);
}
.cards img {
display: block;
width: 100%;
height: 120px;
object-fit: contain;
background: #fff;
}
.cards span {
display: block;
padding: 12px 10px 14px;
font-size: 14px;
font-weight: 600;
line-height: 1.45;
text-align: center;
}
.cards a:hover span {
color: var(--c, #1677ff);
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout">
<nav class="side-nav" aria-label="专家服务子导航">
<NuxtLink
v-for="section in EXPERT_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel" :class="{ 'is-chat': activeKey === 'assistant' }">
<NuxtPage />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { EXPERT_SECTIONS } from "~/composables/useExpertNav"
definePageMeta({ layout: false })
const route = useRoute()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => {
const path = route.path
if (path.startsWith("/expert/assistant")) return "assistant"
if (path.startsWith("/expert/qa") || path.startsWith("/expert/login") || path.startsWith("/expert/register") || path.startsWith("/expert/reset")) {
return "qa"
}
return "guides"
})
const crumb = computed(() => {
const section = EXPERT_SECTIONS.find((item) => item.key === activeKey.value)
return `专家服务 / ${section?.label || "专项估价服务"}`
})
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
.main-panel.is-chat {
padding: 0;
overflow: hidden;
min-height: 640px;
display: flex;
flex-direction: column;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
+394
View File
@@ -0,0 +1,394 @@
<template>
<section class="chat" aria-label="智能估价">
<header class="chat-head">
<div class="brand">
<span class="mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22">
<path
fill="currentColor"
d="M5 5.5A2.5 2.5 0 0 1 7.5 3h9A2.5 2.5 0 0 1 19 5.5v8A2.5 2.5 0 0 1 16.5 16H12l-3.2 3.2c-.6.6-1.8.2-1.8-.7V16H7.5A2.5 2.5 0 0 1 5 13.5v-8Zm2.5-.5a.5.5 0 0 0-.5.5v8c0 .3.2.5.5.5H8v2.4L10.6 15H16.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-9ZM8.5 8h7v1.4h-7V8Zm0 3h5v1.4h-5V11Z"
/>
</svg>
</span>
<div>
<h2>智能估价</h2>
<p>围绕估价知识解答常见问题</p>
</div>
</div>
<div class="head-actions">
<span class="status">样式预览 · 尚未接入大模型</span>
<button type="button" class="text-btn" @click="resetChat">清空对话</button>
</div>
</header>
<div ref="scroller" class="thread" role="log" aria-live="polite" aria-relevant="additions">
<article
v-for="item in messages"
:key="item.id"
class="row"
:class="item.role"
>
<span v-if="item.role === 'assistant'" class="avatar" aria-hidden="true">协</span>
<div class="bubble">
<p>{{ item.text }}</p>
</div>
</article>
<div v-if="showSuggestions" class="suggestions">
<p>可以先试这些问题</p>
<div>
<button
v-for="item in SUGGESTIONS"
:key="item"
type="button"
@click="send(item)"
>{{ item }}</button>
</div>
</div>
<article v-if="replying" class="row assistant">
<span class="avatar" aria-hidden="true">协</span>
<div class="bubble typing" aria-label="正在整理回答">
<span /><span /><span />
</div>
</article>
</div>
<form class="composer" @submit.prevent="send()">
<label class="sr-only" for="assistant-draft">输入问题</label>
<textarea
id="assistant-draft"
ref="draftEl"
v-model="draft"
rows="1"
maxlength="500"
placeholder="请输入与估价相关的问题"
@input="resizeDraft"
@keydown.enter.exact.prevent="send()"
/>
<button type="submit" :disabled="!canSend">发送</button>
<p class="composer-note">Enter 发送,Shift + Enter 换行。回答尚未接入估价知识库,正式咨询请使用专家答疑。</p>
</form>
</section>
</template>
<script setup lang="ts">
const WELCOME = "您好。我可以根据估价知识,解答住宅、商业、工业和特色地产估价中的常见问题。当前只展示对话样式,知识库与大模型尚未接入。"
const PLACEHOLDER = "已收到您的问题。估价知识库与大模型尚未接入,这里只保留对话样式。需要协会工作人员答复时,请使用「专家答疑」留言。"
const SUGGESTIONS = [
"住宅估价通常需要准备哪些资料?",
"商业房地产与住宅估价有什么不同?",
"工业厂房估价常用哪些方法?",
]
type ChatMessage = {
id: number
role: "assistant" | "user"
text: string
}
const messages = ref<ChatMessage[]>([{ id: 1, role: "assistant", text: WELCOME }])
const draft = ref("")
const replying = ref(false)
const scroller = ref<HTMLElement | null>(null)
const draftEl = ref<HTMLTextAreaElement | null>(null)
let seq = 1
let turn = 0
let alive = true
const showSuggestions = computed(() => messages.value.length === 1 && !replying.value)
const canSend = computed(() => draft.value.trim().length > 0 && !replying.value)
function nextId() {
seq += 1
return seq
}
async function scrollToEnd() {
await nextTick()
const el = scroller.value
if (el) el.scrollTop = el.scrollHeight
}
function resizeDraft() {
const el = draftEl.value
if (!el) return
el.style.height = "auto"
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}
async function send(text?: string) {
const content = (text ?? draft.value).trim()
if (!content || replying.value) return
draft.value = ""
await nextTick()
resizeDraft()
messages.value.push({ id: nextId(), role: "user", text: content })
replying.value = true
const ticket = ++turn
await scrollToEnd()
await new Promise((resolve) => setTimeout(resolve, 700))
if (!alive || ticket !== turn) return
messages.value.push({ id: nextId(), role: "assistant", text: PLACEHOLDER })
replying.value = false
await scrollToEnd()
}
async function resetChat() {
turn += 1
replying.value = false
draft.value = ""
messages.value = [{ id: nextId(), role: "assistant", text: WELCOME }]
await nextTick()
resizeDraft()
await scrollToEnd()
}
onScopeDispose(() => {
alive = false
turn += 1
})
</script>
<style scoped>
.chat {
display: flex;
flex-direction: column;
min-height: 640px;
height: min(720px, calc(100vh - 220px));
background:
linear-gradient(180deg, color-mix(in srgb, var(--c, #1677ff) 7%, var(--surface, #fff)), var(--surface, #fff) 120px);
}
.chat-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--line, #d6e4ff);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mark {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
color: #fff;
background: var(--c, #1677ff);
border-radius: 12px;
}
.chat-head h2 {
margin: 0;
font-size: 18px;
font-weight: 650;
letter-spacing: 0.06em;
}
.chat-head p {
margin: 4px 0 0;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.head-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex: none;
}
.status {
padding: 4px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--c, #1677ff) 10%, var(--surface, #fff));
color: var(--c, #1677ff);
font-size: 12px;
letter-spacing: 0.02em;
}
.text-btn {
appearance: none;
border: 0;
background: transparent;
padding: 0;
color: var(--muted, #5b6b85);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.text-btn:hover { color: var(--c, #1677ff); }
.thread {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 22px 22px 8px;
}
.row {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
}
.row.user {
justify-content: flex-end;
}
.avatar {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex: none;
border-radius: 50%;
background: color-mix(in srgb, var(--c, #1677ff) 14%, var(--surface, #fff));
color: var(--c, #1677ff);
font-size: 13px;
font-weight: 650;
}
.bubble {
max-width: min(78%, 560px);
padding: 12px 14px;
border-radius: 4px 14px 14px 14px;
background: color-mix(in srgb, var(--c, #1677ff) 7%, var(--surface, #fff));
border: 1px solid var(--line, #d6e4ff);
}
.bubble p {
margin: 0;
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.row.user .bubble {
border-radius: 14px 4px 14px 14px;
background: var(--c, #1677ff);
border-color: var(--c, #1677ff);
color: #fff;
}
.typing {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 64px;
min-height: 22px;
}
.typing span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted, #5b6b85);
animation: blink 1.1s infinite ease-in-out;
}
.typing span:nth-child(2) { animation-delay: 0.15s; }
.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
.suggestions {
margin: 4px 0 18px 42px;
}
.suggestions p {
margin: 0 0 8px;
font-size: 12px;
color: var(--muted, #5b6b85);
}
.suggestions div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.suggestions button {
appearance: none;
max-width: 100%;
padding: 8px 12px;
border: 1px solid var(--line, #d6e4ff);
border-radius: 999px;
background: var(--surface, #fff);
color: var(--text, #0b1f3a);
font: inherit;
font-size: 13px;
line-height: 1.4;
text-align: left;
cursor: pointer;
}
.suggestions button:hover {
border-color: var(--c, #1677ff);
color: var(--c, #1677ff);
}
.composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px 12px;
padding: 12px 22px 16px;
border-top: 1px solid var(--line, #d6e4ff);
background: var(--surface, #fff);
}
.composer textarea {
width: 100%;
min-height: 44px;
max-height: 120px;
resize: none;
padding: 11px 12px;
border: 1px solid var(--line, #d6e4ff);
border-radius: 12px;
background: var(--surface, #fff);
color: var(--text, #0b1f3a);
font: inherit;
font-size: 14px;
line-height: 1.5;
}
.composer textarea:focus {
outline: 2px solid color-mix(in srgb, var(--c, #1677ff) 35%, transparent);
border-color: var(--c, #1677ff);
}
.composer button[type="submit"] {
align-self: end;
height: 44px;
min-width: 76px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: var(--c, #1677ff);
color: #fff;
font: inherit;
font-size: 14px;
cursor: pointer;
}
.composer button[type="submit"]:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.composer-note {
grid-column: 1 / -1;
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--muted, #5b6b85);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 860px) {
.chat { height: auto; min-height: 560px; }
.chat-head { flex-direction: column; padding: 16px; }
.head-actions { align-items: flex-start; }
.thread { padding: 16px 16px 8px; }
.suggestions { margin-left: 0; }
.bubble { max-width: 88%; }
.composer { padding: 12px 16px 14px; }
}
@media (prefers-reduced-motion: reduce) {
.typing span { animation: none; opacity: 0.7; }
}
</style>
+236
View File
@@ -0,0 +1,236 @@
<template>
<div>
<h2 class="page-title">专项估价服务</h2>
<div class="tabs" role="tablist" aria-label="估价指南分类">
<button
v-for="item in APPRAISAL_KINDS"
:key="item.key"
type="button"
role="tab"
:aria-current="item.key === kind ? 'true' : undefined"
@click="setKind(item.key)"
>{{ item.label }}</button>
</div>
<p v-if="loading" class="hint">列表加载中…</p>
<template v-else-if="region">
<p v-if="region.placeholder" class="placeholder">{{ region.placeholder }}</p>
<template v-else>
<ul v-if="region.items?.length" class="card-list">
<li v-for="item in region.items" :key="item.id">
<NuxtLink class="row" :to="`/articles/${item.id}`">
<div
class="thumb"
:class="{ 'is-empty': !item.imageUrl }"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
:role="item.imageUrl ? 'img' : undefined"
:aria-label="item.imageUrl ? '封面' : '无封面'"
/>
<div class="meta">
<strong>{{ item.title }}</strong>
<div v-if="item.kindLabel" class="meta-sub">{{ item.kindLabel }}</div>
</div>
</NuxtLink>
</li>
</ul>
<nav v-if="Number(region.total || 0) > 0" class="pager" aria-label="列表分页">
<div class="pager-meta">共 {{ region.total }} 条 · 第 {{ region.current }}/{{ pageCount }} 页</div>
<div class="pager-controls">
<div class="pager-nav">
<button type="button" :disabled="region.current <= 1" @click="goPage(region.current - 1)">上一页</button>
<template v-for="(item, index) in visiblePages" :key="`${item}-${index}`">
<span v-if="item === 'ellipsis'" class="pager-ellipsis" aria-hidden="true">…</span>
<button
v-else
type="button"
:aria-current="item === region.current ? 'page' : undefined"
@click="goPage(item)"
>{{ item }}</button>
</template>
<button type="button" :disabled="region.current >= pageCount" @click="goPage(region.current + 1)">下一页</button>
</div>
<label class="pager-size">每页
<select :value="pageSize" aria-label="每页条数" @change="onSizeChange">
<option v-for="n in SIZE_OPTIONS" :key="n" :value="n">{{ n }} 条/页</option>
</select>
</label>
</div>
</nav>
</template>
</template>
</div>
</template>
<script setup lang="ts">
import { APPRAISAL_KINDS } from "~/composables/useExpertNav"
const SIZE_OPTIONS = [10, 20, 100] as const
const DEFAULT_SIZE = 10
const route = useRoute()
const router = useRouter()
const { get } = useSiteApi()
const region = ref<any>(null)
const loading = ref(true)
const kind = computed(() => {
const raw = String(route.query.kind || "all")
return APPRAISAL_KINDS.some((item) => item.key === raw) ? raw : "all"
})
const currentPage = computed(() => {
const raw = Number(route.query.page || 1)
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1
})
const pageSize = computed(() => {
const raw = Number(route.query.size || DEFAULT_SIZE)
return (SIZE_OPTIONS as readonly number[]).includes(raw) ? raw : DEFAULT_SIZE
})
const pageCount = computed(() => {
const total = Number(region.value?.total || 0)
const size = Number(region.value?.size || pageSize.value)
return Math.max(1, Math.ceil(total / size))
})
const visiblePages = computed(() => {
const total = pageCount.value
const current = Math.min(Math.max(1, Number(region.value?.current || 1)), total)
if (total <= 5) return Array.from({ length: total }, (_, i) => i + 1) as Array<number | "ellipsis">
const picked = new Set<number>([1, total, current])
for (let i = current - 1; i <= current + 1; i++) {
if (i >= 1 && i <= total) picked.add(i)
}
const sorted = [...picked].sort((a, b) => a - b)
const items: Array<number | "ellipsis"> = []
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i]! - sorted[i - 1]! > 1) items.push("ellipsis")
items.push(sorted[i]!)
}
return items
})
async function load() {
loading.value = true
try {
const kindQuery = kind.value === "all" ? "" : `&kind=${kind.value}`
region.value = await get<any>(
`/about/articles/appraisal_guides?current=${currentPage.value}&size=${pageSize.value}${kindQuery}`,
)
} finally {
loading.value = false
}
}
function patchQuery(patch: Record<string, string | null>) {
const query: Record<string, string> = { ...route.query } as Record<string, string>
for (const [key, value] of Object.entries(patch)) {
if (value === null || value === "") delete query[key]
else query[key] = value
}
router.push({ query })
}
function setKind(next: string) {
patchQuery({
kind: next === "all" ? null : next,
page: null,
})
}
function goPage(page: number) {
patchQuery({
page: page <= 1 ? null : String(page),
size: pageSize.value === DEFAULT_SIZE ? null : String(pageSize.value),
kind: kind.value === "all" ? null : kind.value,
})
}
function onSizeChange(event: Event) {
const value = Number((event.target as HTMLSelectElement).value)
const size = (SIZE_OPTIONS as readonly number[]).includes(value) ? value : DEFAULT_SIZE
patchQuery({
page: null,
size: size === DEFAULT_SIZE ? null : String(size),
kind: kind.value === "all" ? null : kind.value,
})
}
watch(
() => [kind.value, currentPage.value, pageSize.value] as const,
() => {
void load()
},
{ immediate: true },
)
</script>
<style scoped>
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.tabs {
display: flex; flex-wrap: nowrap; gap: 0; margin: 0 0 16px;
overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
border-bottom: 1px solid var(--line, #d6e4ff);
}
.tabs button {
flex: none; appearance: none; border: 0; background: transparent; cursor: pointer;
padding: 10px 14px; font: inherit; font-size: 14px; color: var(--muted, #5b6b85);
border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.tabs button[aria-current="true"] {
color: var(--c, #1677ff); font-weight: 600; border-bottom-color: var(--c, #1677ff);
}
.placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
border: 1px dashed var(--line, #d6e4ff);
}
.hint { margin: 12px 0 0; text-align: center; color: var(--muted, #5b6b85); font-size: 14px; }
.card-list { margin: 0; padding: 0; list-style: none; }
.card-list li { border-bottom: 1px solid var(--line, #d6e4ff); }
.card-list li:last-child { border-bottom: 0; }
.card-list .row {
display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 16px;
align-items: center; padding: 14px 0; color: inherit; text-decoration: none;
}
.card-list .thumb {
width: 112px; height: 72px; border-radius: 8px;
background: var(--chip, #f7faff) center/cover no-repeat;
border: 1px solid var(--line, #d6e4ff);
}
.card-list .thumb.is-empty {
background: var(--chip, #f7faff) url("/cover-empty.png") center / contain no-repeat;
border-color: transparent;
}
.card-list .meta { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.card-list strong { font-size: 15px; font-weight: 600; line-height: 1.45; }
.card-list .meta-sub { font-size: 12px; color: var(--muted, #5b6b85); }
.card-list .row:hover strong { color: var(--c, #1677ff); }
.pager {
display: flex; flex-direction: column; align-items: center; gap: 10px;
margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--line, #d6e4ff);
}
.pager-meta { font-size: 12px; color: var(--muted, #5b6b85); }
.pager-controls {
display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px 14px; width: 100%;
}
.pager-nav { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
.pager button, .pager select {
min-width: 36px; height: 34px; padding: 0 12px;
border: 1px solid var(--line, #d6e4ff); background: var(--surface, #fff);
color: var(--text, #0b1f3a); border-radius: 8px; font: inherit;
}
.pager button[aria-current="page"] {
background: var(--c, #1677ff); border-color: var(--c, #1677ff); color: #fff;
}
.pager button:disabled { opacity: 0.4; cursor: not-allowed; }
.pager-size {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--muted, #5b6b85);
}
.pager-ellipsis {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 34px; color: var(--muted, #5b6b85);
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts">
await navigateTo("/expert/guides", { replace: true })
</script>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div class="auth-box">
<h2 class="page-title">登录</h2>
<p class="hint">邮箱即账号;验证码发至邮箱。</p>
<label>邮箱</label>
<input v-model="email" type="email" placeholder="name@example.com" autocomplete="username">
<label>密码</label>
<input v-model="password" type="password" placeholder="输入密码" autocomplete="current-password">
<p class="hint">忘记密码?<NuxtLink :to="{ path: '/expert/reset', query: { redirect } }">邮箱验证码重置</NuxtLink></p>
<div class="actions">
<button type="button" class="btn" :disabled="busy" @click="submit">登录</button>
<NuxtLink :to="{ path: '/expert/register', query: { redirect } }">没有账号?注册</NuxtLink>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const { post } = useSiteApi()
const { setSession, ensure, isAuthed } = useMemberAuth()
const email = ref("")
const password = ref("")
const busy = ref(false)
const error = ref("")
const redirect = computed(() => String(route.query.redirect || "/expert/qa"))
await ensure()
if (isAuthed.value) {
await navigateTo(redirect.value, { replace: true })
}
async function submit() {
error.value = ""
busy.value = true
try {
const body = await post<{ accessToken: string }>("/auth/login", {
email: email.value.trim(),
password: password.value,
})
await setSession(body.accessToken)
await router.replace(redirect.value)
} catch (exc: any) {
error.value = exc?.data?.detail || exc?.message || "登录失败"
} finally {
busy.value = false
}
}
</script>
<style scoped>
.auth-box { max-width: 420px; }
.page-title { margin: 0 0 12px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint { margin: 0 0 14px; font-size: 12px; color: var(--muted, #5b6b85); }
.hint a { color: var(--c, #1677ff); }
label { display: block; margin: 0 0 6px; font-size: 13px; color: var(--muted, #5b6b85); }
input {
width: 100%; margin: 0 0 14px; padding: 10px 12px;
border: 1px solid var(--line, #d6e4ff); border-radius: 8px;
background: var(--surface, #fff); color: var(--text, #0b1f3a); font: inherit;
}
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.actions a { font-size: 13px; color: var(--c, #1677ff); text-decoration: none; }
.btn {
appearance: none; border: 0; border-radius: 8px; padding: 10px 16px;
font: inherit; background: var(--c, #1677ff); color: #fff; cursor: pointer;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.error { margin: 12px 0 0; color: #b91c1c; font-size: 13px; }
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<div>
<NuxtLink class="back" to="/expert/qa">← 返回我的提问</NuxtLink>
<p v-if="!ready || loading" class="hint">加载中…</p>
<p v-else-if="!isAuthed" class="hint">请先登录后查看提问。</p>
<p v-else-if="!detail" class="placeholder">提问不存在</p>
<template v-else>
<h2 class="page-title">{{ detail.title }}</h2>
<div class="meta-sub">
<span class="tag" :class="tagClass(detail)">{{ statusText(detail) }}</span>
<time>{{ detail.createdAt }}</time>
</div>
<h3 class="block-title">提问</h3>
<div class="prose">{{ detail.body }}</div>
<div v-if="detail.attachments?.length" class="attach">
<h4>附件</h4>
<ul class="thumb-grid">
<li v-for="file in detail.attachments" :key="file.id">
<a :href="file.url" target="_blank" rel="noopener" class="thumb-card" :title="file.filename">
<div class="thumb-preview">
<img v-if="isImage(file)" :src="file.url" :alt="file.filename">
<span v-else class="thumb-ext">{{ fileExt(file.filename) }}</span>
</div>
<div class="thumb-name">{{ file.filename }}</div>
</a>
</li>
</ul>
</div>
<h3 class="block-title">回复</h3>
<template v-if="detail.reply">
<div class="prose" v-html="detail.reply.bodyHtml" />
<div v-if="detail.reply.attachments?.length" class="attach">
<h4>附件</h4>
<ul class="thumb-grid">
<li v-for="file in detail.reply.attachments" :key="file.id">
<a :href="file.url" target="_blank" rel="noopener" class="thumb-card" :title="file.filename">
<div class="thumb-preview">
<img v-if="isImage(file)" :src="file.url" :alt="file.filename">
<span v-else class="thumb-ext">{{ fileExt(file.filename) }}</span>
</div>
<div class="thumb-name">{{ file.filename }}</div>
</a>
</li>
</ul>
</div>
</template>
<p v-else class="hint-inline">专家委员会将及时回复。</p>
</template>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { get } = useSiteApi()
const { token, ready, isAuthed, ensure } = useMemberAuth()
const loading = ref(true)
const detail = ref<any>(null)
function tagClass(item: any) {
if (item.status === "replied") return "done"
if (item.overdue) return "overdue"
return "pending"
}
function statusText(item: any) {
if (item.status === "replied") return "已回复"
if (item.overdue) return "超时待回复"
return "待回复"
}
function fileExt(name: string) {
const part = String(name || "").split(".").pop()?.trim()
return (part && part !== name ? part : "FILE").slice(0, 6).toUpperCase()
}
function isImage(file: { contentType?: string; filename?: string }) {
if (file.contentType?.startsWith("image/")) return true
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(file.filename || "")
}
async function load() {
loading.value = true
detail.value = null
if (!token.value) {
loading.value = false
return
}
try {
detail.value = await get(`/member/expert-questions/${route.params.id}`, token.value)
} catch {
detail.value = null
} finally {
loading.value = false
}
}
await ensure()
watch(
() => [route.params.id, token.value] as const,
() => {
void load()
},
{ immediate: true },
)
</script>
<style scoped>
.back {
display: inline-block; margin-bottom: 12px; font-size: 13px;
color: var(--c, #1677ff); text-decoration: none;
}
.page-title { margin: 0 0 12px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.meta-sub {
display: flex; flex-wrap: wrap; gap: 8px 14px; margin-bottom: 14px;
font-size: 12px; color: var(--muted, #5b6b85);
}
.block-title { margin: 22px 0 8px; font-size: 15px; }
.prose { font-size: 15px; line-height: 1.85; white-space: pre-wrap; }
.prose :deep(p) { margin: 0 0 1em; }
.attach { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line, #d6e4ff); }
.attach h4 { margin: 0 0 10px; font-size: 14px; color: var(--muted, #5b6b85); }
.thumb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.thumb-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--line, #d6e4ff);
border-radius: 10px;
background: var(--chip, #f7faff);
color: inherit;
text-decoration: none;
}
.thumb-preview {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
background: #fff;
border: 1px solid var(--line, #d6e4ff);
}
.thumb-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-ext {
font-size: 12px;
font-weight: 650;
letter-spacing: 0.04em;
color: var(--muted, #5b6b85);
}
.thumb-name {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hint, .hint-inline { color: var(--muted, #5b6b85); font-size: 13px; }
.placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); border: 1px dashed var(--line, #d6e4ff);
}
.tag {
display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px;
font-size: 12px; border: 1px solid var(--line, #d6e4ff); background: var(--chip, #f7faff);
}
.tag.pending { color: #b45309; border-color: #fcd34d; background: #fffbeb; }
.tag.done { color: #047857; border-color: #6ee7b7; background: #ecfdf5; }
.tag.overdue { color: #b91c1c; border-color: #fca5a5; background: #fef2f2; }
</style>
+329
View File
@@ -0,0 +1,329 @@
<template>
<div>
<template v-if="!ready">
<p class="hint">加载中…</p>
</template>
<template v-else-if="!isAuthed">
<h2 class="page-title">专家答疑</h2>
<p class="gate">提问前请先登录。登录后仅可查看本人提问与回复。</p>
<div class="actions">
<NuxtLink class="btn" :to="loginTo">登录</NuxtLink>
<NuxtLink class="btn ghost" :to="registerTo">注册</NuxtLink>
</div>
</template>
<template v-else>
<div class="head">
<h2 class="page-title">我的提问</h2>
<div class="actions">
<span class="who">{{ email }}</span>
<button type="button" class="btn ghost" @click="logout">退出</button>
<button type="button" class="btn" @click="toggleAsk">{{ showAsk ? "收起提问" : "提交提问" }}</button>
</div>
</div>
<div v-if="showAsk" class="ask-box">
<label>标题</label>
<input v-model="form.title" type="text" maxlength="200" placeholder="简明描述问题">
<label>正文</label>
<textarea v-model="form.body" placeholder="说明背景与希望解答的要点" />
<label>附件(可选,可多选)</label>
<div class="attach-toolbar">
<input ref="fileInput" type="file" multiple class="file-input" @change="onFiles">
<button type="button" class="btn ghost" @click="pickFiles">选择附件</button>
</div>
<ul v-if="pendingFiles.length" class="thumb-grid">
<li v-for="item in pendingFiles" :key="item.key" class="thumb-card">
<div class="thumb-preview">
<img v-if="item.previewUrl" :src="item.previewUrl" :alt="item.file.name">
<span v-else class="thumb-ext">{{ fileExt(item.file.name) }}</span>
</div>
<div class="thumb-name" :title="item.file.name">{{ item.file.name }}</div>
<button type="button" class="thumb-remove" @click="removePending(item.key)">删除</button>
</li>
</ul>
<div class="actions">
<button type="button" class="btn" :disabled="submitting" @click="submit">提交</button>
<span v-if="error" class="error">{{ error }}</span>
</div>
</div>
<p v-if="loading" class="hint">列表加载中…</p>
<p v-else-if="!items.length" class="placeholder">暂无提问记录</p>
<ul v-else class="qa-list">
<li v-for="item in items" :key="item.id">
<NuxtLink :to="`/expert/qa/${item.id}`">
<h3>{{ item.title }}</h3>
<div class="meta-sub">
<time>{{ item.createdAt }}</time>
<span class="tag" :class="tagClass(item)">{{ statusText(item) }}</span>
</div>
</NuxtLink>
</li>
</ul>
</template>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const { get, post, upload } = useSiteApi()
const { token, email, ready, isAuthed, ensure, clearSession } = useMemberAuth()
type PendingFile = {
key: string
file: File
previewUrl: string
}
const loading = ref(true)
const items = ref<any[]>([])
const showAsk = ref(false)
const submitting = ref(false)
const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null)
const pendingFiles = ref<PendingFile[]>([])
const form = reactive({
title: "",
body: "",
})
const redirect = computed(() => String(route.query.redirect || "/expert/qa"))
const loginTo = computed(() => ({ path: "/expert/login", query: { redirect: redirect.value } }))
const registerTo = computed(() => ({ path: "/expert/register", query: { redirect: redirect.value } }))
function tagClass(item: any) {
if (item.status === "replied") return "done"
if (item.overdue) return "overdue"
return "pending"
}
function statusText(item: any) {
if (item.status === "replied") return "已回复"
if (item.overdue) return "超时待回复"
return "待回复"
}
function fileExt(name: string) {
const part = name.split(".").pop()?.trim()
return (part && part !== name ? part : "FILE").slice(0, 6).toUpperCase()
}
function isImageFile(file: File) {
if (file.type.startsWith("image/")) return true
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(file.name)
}
function clearPending() {
for (const item of pendingFiles.value) {
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
}
pendingFiles.value = []
}
function pickFiles() {
fileInput.value?.click()
}
function onFiles(event: Event) {
const input = event.target as HTMLInputElement
const files = [...(input.files || [])]
input.value = ""
for (const file of files) {
const key = `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2, 8)}`
pendingFiles.value.push({
key,
file,
previewUrl: isImageFile(file) ? URL.createObjectURL(file) : "",
})
}
}
function removePending(key: string) {
const target = pendingFiles.value.find((item) => item.key === key)
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl)
pendingFiles.value = pendingFiles.value.filter((item) => item.key !== key)
}
function toggleAsk() {
showAsk.value = !showAsk.value
if (!showAsk.value) {
error.value = ""
}
}
async function load() {
if (!token.value) {
items.value = []
loading.value = false
return
}
loading.value = true
try {
const body = await get<{ items: any[] }>("/member/expert-questions", token.value)
items.value = body.items || []
} catch {
items.value = []
} finally {
loading.value = false
}
}
async function submit() {
error.value = ""
if (!form.title.trim() || !form.body.trim()) {
error.value = "请填写标题和正文"
return
}
submitting.value = true
try {
const attachmentFileIds: number[] = []
if (token.value) {
for (const item of pendingFiles.value) {
const data = new FormData()
data.append("file", item.file)
const uploaded = await upload<{ id: number }>("/member/files", data, token.value)
attachmentFileIds.push(uploaded.id)
}
}
await post(
"/member/expert-questions",
{ title: form.title.trim(), body: form.body.trim(), attachmentFileIds },
token.value,
)
form.title = ""
form.body = ""
clearPending()
showAsk.value = false
await load()
} catch (exc: any) {
error.value = exc?.data?.detail || exc?.message || "提交失败"
} finally {
submitting.value = false
}
}
function logout() {
clearSession()
items.value = []
clearPending()
router.push("/expert/qa")
}
onBeforeUnmount(clearPending)
await ensure()
watch(isAuthed, () => {
void load()
}, { immediate: true })
</script>
<style scoped>
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.head {
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px;
margin-bottom: 8px;
}
.head .page-title { margin: 0; }
.gate { margin: 0 0 16px; color: var(--muted, #5b6b85); font-size: 14px; line-height: 1.7; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.who { font-size: 13px; color: var(--muted, #5b6b85); }
.btn {
display: inline-flex; align-items: center; justify-content: center;
appearance: none; border: 0; border-radius: 8px; padding: 10px 16px;
font: inherit; background: var(--c, #1677ff); color: #fff; cursor: pointer; text-decoration: none;
}
.btn.ghost {
background: var(--chip, #f7faff); color: var(--text, #0b1f3a); border: 1px solid var(--line, #d6e4ff);
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.ask-box { max-width: 640px; margin: 0 0 22px; }
.ask-box label { display: block; margin: 0 0 6px; font-size: 13px; color: var(--muted, #5b6b85); }
.ask-box input:not([type="file"]), .ask-box textarea {
width: 100%; margin: 0 0 14px; padding: 10px 12px;
border: 1px solid var(--line, #d6e4ff); border-radius: 8px;
background: var(--surface, #fff); color: var(--text, #0b1f3a); font: inherit;
}
.ask-box textarea { min-height: 120px; resize: vertical; }
.attach-toolbar { margin: 0 0 12px; }
.file-input { display: none; }
.thumb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 12px;
margin: 0 0 14px;
padding: 0;
list-style: none;
}
.thumb-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--line, #d6e4ff);
border-radius: 10px;
background: var(--chip, #f7faff);
}
.thumb-preview {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
background: #fff;
border: 1px solid var(--line, #d6e4ff);
}
.thumb-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-ext {
font-size: 12px;
font-weight: 650;
letter-spacing: 0.04em;
color: var(--muted, #5b6b85);
}
.thumb-name {
font-size: 12px;
color: var(--text, #0b1f3a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.thumb-remove {
appearance: none;
border: 0;
background: transparent;
color: #b91c1c;
font: inherit;
font-size: 12px;
cursor: pointer;
padding: 0;
text-align: left;
}
.hint, .hint-inline { margin: 0 0 12px; color: var(--muted, #5b6b85); font-size: 13px; }
.error { color: #b91c1c; font-size: 13px; }
.placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
border: 1px dashed var(--line, #d6e4ff);
}
.qa-list { margin: 0; padding: 0; list-style: none; }
.qa-list li { border-bottom: 1px solid var(--line, #d6e4ff); padding: 14px 0; }
.qa-list li:last-child { border-bottom: 0; }
.qa-list a { display: block; color: inherit; text-decoration: none; }
.qa-list h3 { margin: 0 0 6px; font-size: 15px; font-weight: 600; }
.qa-list .meta-sub {
display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: 12px; color: var(--muted, #5b6b85);
}
.tag {
display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px;
font-size: 12px; background: var(--chip, #f7faff); color: var(--muted, #5b6b85);
border: 1px solid var(--line, #d6e4ff);
}
.tag.pending { color: #b45309; border-color: #fcd34d; background: #fffbeb; }
.tag.done { color: #047857; border-color: #6ee7b7; background: #ecfdf5; }
.tag.overdue { color: #b91c1c; border-color: #fca5a5; background: #fef2f2; }
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="auth-box">
<h2 class="page-title">注册</h2>
<p class="hint">邮箱即账号;验证码发至邮箱。</p>
<label>邮箱</label>
<input v-model="email" type="email" placeholder="name@example.com" autocomplete="username">
<label>邮箱验证码</label>
<div class="code-row">
<input v-model="code" type="text" placeholder="6 位验证码" maxlength="6">
<button type="button" class="btn ghost" :disabled="codeBusy || cooldown > 0" @click="sendCode">
{{ cooldown > 0 ? `${cooldown}s` : "获取验证码" }}
</button>
</div>
<label>密码</label>
<input v-model="password" type="password" placeholder="至少 8 位,含字母和数字" autocomplete="new-password">
<label class="check">
<input v-model="privacyAccepted" type="checkbox">
<span>我已阅读并同意 <NuxtLink to="/legal/privacy" target="_blank">隐私政策</NuxtLink></span>
</label>
<div class="actions">
<button type="button" class="btn" :disabled="busy" @click="submit">注册并登录</button>
<NuxtLink :to="{ path: '/expert/login', query: { redirect } }">已有账号?登录</NuxtLink>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const { post } = useSiteApi()
const { setSession, ensure, isAuthed } = useMemberAuth()
const email = ref("")
const code = ref("")
const password = ref("")
const privacyAccepted = ref(false)
const busy = ref(false)
const codeBusy = ref(false)
const cooldown = ref(0)
const error = ref("")
const redirect = computed(() => String(route.query.redirect || "/expert/qa"))
let timer: ReturnType<typeof setInterval> | undefined
await ensure()
if (isAuthed.value) {
await navigateTo(redirect.value, { replace: true })
}
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
function startCooldown() {
cooldown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
cooldown.value -= 1
if (cooldown.value <= 0 && timer) {
clearInterval(timer)
timer = undefined
}
}, 1000)
}
async function sendCode() {
error.value = ""
codeBusy.value = true
try {
await post("/auth/email-code", { email: email.value.trim(), purpose: "register" })
startCooldown()
} catch (exc: any) {
error.value = exc?.data?.detail || exc?.message || "验证码发送失败"
} finally {
codeBusy.value = false
}
}
async function submit() {
error.value = ""
if (!privacyAccepted.value) {
error.value = "请先阅读并同意隐私政策"
return
}
busy.value = true
try {
const body = await post<{ accessToken: string }>("/auth/register", {
email: email.value.trim(),
code: code.value.trim(),
password: password.value,
privacyAccepted: true,
})
await setSession(body.accessToken)
await router.replace(redirect.value)
} catch (exc: any) {
error.value = exc?.data?.detail || exc?.message || "注册失败"
} finally {
busy.value = false
}
}
</script>
<style scoped>
.auth-box { max-width: 420px; }
.page-title { margin: 0 0 12px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint { margin: 0 0 14px; font-size: 12px; color: var(--muted, #5b6b85); }
label { display: block; margin: 0 0 6px; font-size: 13px; color: var(--muted, #5b6b85); }
input[type="email"], input[type="text"], input[type="password"] {
width: 100%; margin: 0 0 14px; padding: 10px 12px;
border: 1px solid var(--line, #d6e4ff); border-radius: 8px;
background: var(--surface, #fff); color: var(--text, #0b1f3a); font: inherit;
}
.code-row {
display: flex; gap: 10px; align-items: center; margin: 0 0 14px;
}
.code-row input { margin: 0; flex: 1; }
.check {
display: flex; gap: 8px; align-items: flex-start; margin: 0 0 14px;
color: var(--text, #0b1f3a); font-size: 13px;
}
.check input { width: auto; margin: 3px 0 0; }
.check a { color: var(--c, #1677ff); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.actions a { font-size: 13px; color: var(--c, #1677ff); text-decoration: none; }
.btn {
appearance: none; border: 0; border-radius: 8px; padding: 10px 16px;
font: inherit; background: var(--c, #1677ff); color: #fff; cursor: pointer;
}
.btn.ghost {
background: var(--chip, #f7faff); color: var(--text, #0b1f3a);
border: 1px solid var(--line, #d6e4ff); white-space: nowrap;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.error { margin: 12px 0 0; color: #b91c1c; font-size: 13px; }
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="auth-box">
<h2 class="page-title">重置密码</h2>
<p class="hint">使用邮箱验证码设置新密码。</p>
<label>邮箱</label>
<input v-model="email" type="email" placeholder="name@example.com" autocomplete="username">
<label>邮箱验证码</label>
<div class="code-row">
<input v-model="code" type="text" placeholder="6 位验证码" maxlength="6">
<button type="button" class="btn ghost" :disabled="codeBusy || cooldown > 0" @click="sendCode">
{{ cooldown > 0 ? `${cooldown}s` : "获取验证码" }}
</button>
</div>
<label>新密码</label>
<input v-model="password" type="password" placeholder="至少 8 位,含字母和数字" autocomplete="new-password">
<div class="actions">
<button type="button" class="btn" :disabled="busy" @click="submit">重置并登录</button>
<NuxtLink :to="{ path: '/expert/login', query: { redirect } }">返回登录</NuxtLink>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const { post } = useSiteApi()
const { setSession } = useMemberAuth()
const email = ref("")
const code = ref("")
const password = ref("")
const busy = ref(false)
const codeBusy = ref(false)
const cooldown = ref(0)
const error = ref("")
const redirect = computed(() => String(route.query.redirect || "/expert/qa"))
let timer: ReturnType<typeof setInterval> | undefined
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
function startCooldown() {
cooldown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
cooldown.value -= 1
if (cooldown.value <= 0 && timer) {
clearInterval(timer)
timer = undefined
}
}, 1000)
}
async function sendCode() {
error.value = ""
codeBusy.value = true
try {
await post("/auth/email-code", { email: email.value.trim(), purpose: "reset" })
startCooldown()
} catch (exc: any) {
error.value = exc?.data?.detail || exc?.message || "验证码发送失败"
} finally {
codeBusy.value = false
}
}
async function submit() {
error.value = ""
busy.value = true
try {
const body = await post<{ accessToken: string }>("/auth/reset-password", {
email: email.value.trim(),
code: code.value.trim(),
password: password.value,
})
await setSession(body.accessToken)
await router.replace(redirect.value)
} catch (exc: any) {
error.value = exc?.data?.detail || exc?.message || "重置失败"
} finally {
busy.value = false
}
}
</script>
<style scoped>
.auth-box { max-width: 420px; }
.page-title { margin: 0 0 12px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint { margin: 0 0 14px; font-size: 12px; color: var(--muted, #5b6b85); }
label { display: block; margin: 0 0 6px; font-size: 13px; color: var(--muted, #5b6b85); }
input[type="email"], input[type="text"], input[type="password"] {
width: 100%; margin: 0 0 14px; padding: 10px 12px;
border: 1px solid var(--line, #d6e4ff); border-radius: 8px;
background: var(--surface, #fff); color: var(--text, #0b1f3a); font: inherit;
}
.code-row { display: flex; gap: 10px; align-items: center; margin: 0 0 14px; }
.code-row input { margin: 0; flex: 1; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.actions a { font-size: 13px; color: var(--c, #1677ff); text-decoration: none; }
.btn {
appearance: none; border: 0; border-radius: 8px; padding: 10px 16px;
font: inherit; background: var(--c, #1677ff); color: #fff; cursor: pointer;
}
.btn.ghost {
background: var(--chip, #f7faff); color: var(--text, #0b1f3a);
border: 1px solid var(--line, #d6e4ff); white-space: nowrap;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.error { margin: 12px 0 0; color: #b91c1c; font-size: 13px; }
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / 资质荣誉</p>
<div class="main-panel">
<AboutArticleList title="资质荣誉" category="qualifications" />
</div>
</SiteHome>
</template>
<script setup lang="ts">
definePageMeta({ layout: false })
const { home, logoUrl, ensureChrome } = useSiteChrome()
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
margin: 0 0 40px;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<SiteHome v-if="home" :home="home" :logo-url="logoUrl" />
<SiteLoading v-else hint="正在读取首页" />
</template>
<script setup lang="ts">
definePageMeta({ layout: false })
const { home, logoUrl, ensureChrome } = useSiteChrome()
onMounted(() => {
ensureChrome()
})
</script>
+33
View File
@@ -0,0 +1,33 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / 机构名录</p>
<div class="main-panel">
<AboutArticleList title="机构名录" category="institution_directories" />
</div>
</SiteHome>
</template>
<script setup lang="ts">
definePageMeta({ layout: false })
const { home, logoUrl, ensureChrome } = useSiteChrome()
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
margin: 0 0 40px;
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ title }}</p>
<div class="main-panel">
<h2 class="page-title">{{ title }}</h2>
<p v-if="loading" class="hint">正文加载中…</p>
<p v-else-if="page?.placeholder" class="placeholder">{{ page.placeholder }}</p>
<div v-else-if="page" class="prose" v-html="page.bodyHtml" />
</div>
</SiteHome>
</template>
<script setup lang="ts">
definePageMeta({ layout: false })
const TITLES: Record<string, string> = {
privacy: "隐私政策",
terms: "使用条款",
statement: "网站声明",
}
const route = useRoute()
const key = computed(() => String(route.params.key || ""))
const title = computed(() => TITLES[key.value] || "合规说明")
const { get } = useSiteApi()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const page = ref<any>(null)
const loading = ref(true)
if (!TITLES[key.value]) {
throw createError({ statusCode: 404, statusMessage: "合规页不存在" })
}
await ensureChrome()
watch(
key,
async (next) => {
if (!TITLES[next]) return
loading.value = true
try {
page.value = await get<any>(`/legal/${next}`)
} finally {
loading.value = false
}
},
{ immediate: true },
)
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 280px;
margin-bottom: 40px;
}
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint, .placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
}
.placeholder { border: 1px dashed var(--line, #d6e4ff); }
.prose { font-size: 15px; line-height: 1.85; }
.prose :deep(p) { margin: 0 0 1em; }
.prose :deep(h3) { margin: 1.4em 0 0.6em; font-size: 17px; }
.prose :deep(ul) { margin: 0 0 1em; padding-left: 1.2em; }
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout">
<nav class="side-nav" aria-label="资料库">
<NuxtLink
v-for="section in LIBRARY_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel">
<NuxtPage />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { LIBRARY_SECTIONS } from "~/composables/useLibraryNav"
definePageMeta({ layout: false })
const route = useRoute()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => (route.path.startsWith("/library/cases") ? "cases" : "policies"))
const crumb = computed(() => {
const section = LIBRARY_SECTIONS.find((item) => item.key === activeKey.value)
const base = `资料库 / ${section?.label || "政策分析"}`
if (route.path.startsWith("/library/cases/upload")) return `${base} / 案例上传`
return base
})
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<template>
<div>
<p v-if="uploaded" class="done">提交成功</p>
<AboutArticleList title="案例库" category="case_library" show-cover>
<template #actions>
<NuxtLink class="upload-btn" to="/library/cases/upload">案例上传</NuxtLink>
</template>
</AboutArticleList>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const uploaded = computed(() => route.query.uploaded === "1")
</script>
<style scoped>
.done {
margin: 0 0 12px;
color: var(--c, #1677ff);
}
.upload-btn {
display: inline-flex;
align-items: center;
height: 34px;
padding: 0 14px;
border-radius: 8px;
background: var(--c, #1677ff);
color: #fff;
font-size: 14px;
text-decoration: none;
white-space: nowrap;
}
</style>
+550
View File
@@ -0,0 +1,550 @@
<template>
<div class="upload-page">
<p v-if="!ready" class="hint">加载中…</p>
<template v-else-if="isAuthed">
<div v-if="!formOpen" class="cards">
<button
v-for="card in cards"
:key="card.key"
type="button"
class="card"
@click="openCard(card.key)"
>
<span class="card-icon" :style="{ '--icon': `url(${card.icon})` }" aria-hidden="true" />
<span>{{ card.label }}</span>
</button>
</div>
<p v-if="notice" class="hint">{{ notice }}</p>
<form v-if="formOpen" class="form" @submit.prevent="submit">
<button type="button" class="back wide" @click="closeForm">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.5 6.5 9 12l5.5 5.5" />
</svg>
返回
</button>
<h2 class="page-title wide">{{ formTitle }}</h2>
<p v-if="!options" class="hint wide">表单加载中…</p>
<template v-else>
<div class="field">
<label>案例类型</label>
<select v-model="form.caseType" required>
<option value="">请选择</option>
<option v-for="item in options.caseTypes" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div class="field">
<label>案例时间</label>
<input v-model="form.caseDate" type="date" required>
</div>
<div class="field wide">
<label>地址</label>
<div class="split">
<select v-model="form.province" required @change="onProvince">
<option value="">省份</option>
<option v-for="item in regions" :key="item.name" :value="item.name">{{ item.name }}</option>
</select>
<select v-model="form.city" required @change="onCity">
<option value="">城市</option>
<option v-for="item in cities" :key="item.name" :value="item.name">{{ item.name }}</option>
</select>
<select v-model="form.district" required>
<option value="">区县</option>
<option v-for="name in districts" :key="name" :value="name">{{ name }}</option>
</select>
</div>
</div>
<div class="field">
<label>楼盘名称</label>
<input v-model="form.estateName" required maxlength="200">
</div>
<div class="field">
<label>建筑结构</label>
<select v-model="form.structure" required>
<option value="">请选择</option>
<option v-for="item in options.structures" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div class="field">
<label>坐落</label>
<input v-model="form.location" required maxlength="500">
</div>
<div class="field">
<label>建筑年代</label>
<select v-model="form.builtYear" required>
<option value="">请选择</option>
<option v-for="year in options.builtYears" :key="year" :value="String(year)">{{ year }}</option>
</select>
</div>
<div class="field">
<label>总层数</label>
<select v-model="form.totalFloors" required>
<option value="">请选择</option>
<option v-for="item in options.totalFloors" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div v-if="activeKind !== 'industrial'" class="field">
<label>所在层数</label>
<select v-model="form.floorNo" required>
<option value="">请选择</option>
<option v-for="item in options.floorNos" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div v-if="activeKind === 'residential'" class="field">
<label>用途</label>
<select v-model="form.purpose" required>
<option value="">请选择</option>
<option v-for="item in options.purposes" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div v-if="activeKind === 'residential' || activeKind === 'office'" class="field">
<label>户型结构</label>
<select v-model="form.layoutStructure" required>
<option value="">请选择</option>
<option v-for="item in options.layoutStructures" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div class="field">
<label>装修档次</label>
<select v-model="form.decoration" required>
<option value="">请选择</option>
<option v-for="item in options.decorations" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<fieldset class="choices wide">
<legend>价值包含(可不选,最多 3 项)</legend>
<label v-for="item in options.valueIncludes" :key="item" class="check">
<input v-model="form.valueIncludes" type="checkbox" :value="item" @change="limitIncludes">
<span>{{ item }}</span>
</label>
</fieldset>
<div class="field">
<label>建筑面积(㎡)</label>
<input v-model="form.area" required inputmode="decimal">
</div>
<div v-if="activeKind !== 'office'" class="field">
<label>户型</label>
<select v-model="form.layout" required>
<option value="">请选择</option>
<option v-for="item in options.layouts" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<template v-if="activeKind === 'commercial'">
<div class="field">
<label>临街状况</label>
<select v-model="form.streetFrontage" required>
<option value="">请选择</option>
<option v-for="item in options.streetFrontages" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div class="field">
<label>人流量</label>
<select v-model="form.footTraffic" required>
<option value="">请选择</option>
<option v-for="item in options.footTraffics" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<fieldset class="choices wide">
<legend>商铺类型</legend>
<label v-for="item in options.shopTypes" :key="item" class="check">
<input v-model="form.shopType" type="radio" name="shopType" :value="item" required>
<span>{{ item }}</span>
</label>
</fieldset>
<fieldset class="choices wide">
<legend>经营业态(至少 1 项,最多 3 项)</legend>
<label v-for="item in options.businessFormats" :key="item" class="check">
<input v-model="form.businessFormats" type="checkbox" :value="item" @change="limitFormats">
<span>{{ item }}</span>
</label>
</fieldset>
</template>
<template v-if="activeKind === 'industrial'">
<div class="field">
<label>走向</label>
<select v-model="form.heading" required>
<option value="">请选择</option>
<option v-for="item in options.headings" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div class="field">
<label>临路状况</label>
<select v-model="form.roadFrontage" required>
<option value="">请选择</option>
<option v-for="item in options.roadFrontages" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<fieldset class="choices wide">
<legend>工业类型</legend>
<label v-for="item in options.industryTypes" :key="item" class="check">
<input v-model="form.industryType" type="radio" name="industryType" :value="item" required>
<span>{{ item }}</span>
</label>
</fieldset>
</template>
<div class="field">
<label>朝向</label>
<select v-model="form.orientation" required>
<option value="">请选择</option>
<option v-for="item in options.orientations" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div class="field">
<label>单价(元)</label>
<input v-model="form.unitPrice" required inputmode="decimal">
</div>
<div class="field wide">
<label>其他特殊情况及备注</label>
<textarea v-model="form.remark" maxlength="1000" />
</div>
<div class="field wide">
<label>位置图及案例照片</label>
<input type="file" multiple @change="onFiles">
<ul v-if="photos.length" class="files">
<li v-for="file in photos" :key="file.id">{{ file.filename }}</li>
</ul>
</div>
<div class="field">
<label>提交机构名称</label>
<input v-model="form.institutionName" required maxlength="200">
</div>
<button type="submit" class="submit wide" :disabled="submitting">提交案例</button>
<p v-if="error" class="error wide">{{ error }}</p>
</template>
</form>
</template>
</div>
</template>
<script setup lang="ts">
const router = useRouter()
const { get, post, upload } = useSiteApi()
const { token, ready, isAuthed, ensure } = useMemberAuth()
const cards = [
{ key: "residential", label: "住宅案例提交", icon: "/icons/case-residential.png" },
{ key: "commercial", label: "商业案例提交", icon: "/icons/case-commercial.png" },
{ key: "office", label: "办公案例提交", icon: "/icons/case-office.png" },
{ key: "industrial", label: "工业案例提交", icon: "/icons/case-industrial.png" },
]
const formOpen = ref(false)
const notice = ref("")
const activeKind = ref("")
const openKinds = ref<string[]>([])
const bundles = ref<Record<string, any>>({})
const options = computed(() => bundles.value[activeKind.value] || null)
const formTitle = computed(() => {
if (activeKind.value === "commercial") return "商业案例数据提交"
if (activeKind.value === "office") return "办公案例数据提交"
if (activeKind.value === "industrial") return "工业案例数据提交"
return "住宅案例数据提交"
})
const regions = ref<any[]>([])
const photos = ref<{ id: number; filename: string }[]>([])
const submitting = ref(false)
const error = ref("")
const form = reactive({
caseType: "",
province: "",
city: "",
district: "",
estateName: "",
structure: "",
caseDate: "",
location: "",
builtYear: "",
totalFloors: "",
floorNo: "",
purpose: "",
layoutStructure: "",
decoration: "",
valueIncludes: [] as string[],
area: "",
layout: "",
orientation: "",
streetFrontage: "",
footTraffic: "",
shopType: "",
businessFormats: [] as string[],
heading: "",
roadFrontage: "",
industryType: "",
unitPrice: "",
remark: "",
institutionName: "",
})
const cities = computed(() => regions.value.find((item) => item.name === form.province)?.sub || [])
const districts = computed(() => cities.value.find((item) => item.name === form.city)?.sub?.map((item: any) => item.name) || [])
function onProvince() {
form.city = ""
form.district = ""
}
function onCity() {
form.district = ""
}
function limitIncludes() {
if (form.valueIncludes.length > 3) form.valueIncludes = form.valueIncludes.slice(0, 3)
}
function limitFormats() {
if (form.businessFormats.length > 3) form.businessFormats = form.businessFormats.slice(0, 3)
}
function openCard(key: string) {
notice.value = ""
if (!openKinds.value.includes(key)) {
notice.value = "该类型尚未开放"
return
}
activeKind.value = key
formOpen.value = true
}
function closeForm() {
formOpen.value = false
error.value = ""
}
async function loadOptions() {
const body = await get<any>("/uploaded-case-options")
openKinds.value = body.openKinds || []
bundles.value = {
residential: body.residential,
commercial: body.commercial,
office: body.office,
industrial: body.industrial,
}
regions.value = body.regions || []
}
async function onFiles(event: Event) {
const input = event.target as HTMLInputElement
const files = [...(input.files || [])]
input.value = ""
error.value = ""
for (const file of files) {
if (file.size > 5 * 1024 * 1024) {
error.value = "位置图及案例照片须小于 5MB"
continue
}
const data = new FormData()
data.append("file", file)
try {
const saved = await upload<{ id: number; filename: string }>("/member/files", data, token.value)
photos.value.push({ id: saved.id, filename: saved.filename || file.name })
} catch (exc: any) {
error.value = exc?.data?.detail || exc?.message || "照片上传失败"
}
}
}
async function submit() {
error.value = ""
if (!photos.value.length) {
error.value = "请上传位置图及案例照片"
return
}
if (activeKind.value === "commercial" && !form.businessFormats.length) {
error.value = "请选择经营业态"
return
}
submitting.value = true
const payload: Record<string, unknown> = {
kind: activeKind.value,
caseType: form.caseType,
province: form.province,
city: form.city,
district: form.district,
estateName: form.estateName,
structure: form.structure,
caseDate: form.caseDate,
location: form.location,
builtYear: Number(form.builtYear),
totalFloors: form.totalFloors,
decoration: form.decoration,
valueIncludes: form.valueIncludes,
area: form.area,
orientation: form.orientation,
unitPrice: form.unitPrice,
remark: form.remark,
institutionName: form.institutionName,
photoFileIds: photos.value.map((item) => item.id),
}
if (activeKind.value !== "industrial") {
payload.floorNo = form.floorNo
}
if (activeKind.value !== "office") {
payload.layout = form.layout
}
if (activeKind.value === "residential") {
payload.purpose = form.purpose
payload.layoutStructure = form.layoutStructure
}
if (activeKind.value === "office") {
payload.layoutStructure = form.layoutStructure
}
if (activeKind.value === "commercial") {
payload.streetFrontage = form.streetFrontage
payload.footTraffic = form.footTraffic
payload.shopType = form.shopType
payload.businessFormats = form.businessFormats
}
if (activeKind.value === "industrial") {
payload.heading = form.heading
payload.roadFrontage = form.roadFrontage
payload.industryType = form.industryType
}
try {
await post(
"/member/uploaded-cases",
payload,
token.value,
)
await router.replace({ path: "/library/cases", query: { uploaded: "1" } })
} catch (exc: any) {
error.value = exc?.data?.detail || exc?.message || "提交失败"
} finally {
submitting.value = false
}
}
onMounted(async () => {
await ensure()
if (!isAuthed.value) {
await navigateTo({ path: "/expert/login", query: { redirect: "/library/cases/upload" } })
return
}
await loadOptions()
})
</script>
<style scoped>
.upload-page { container-type: inline-size; }
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 120px;
padding: 16px 8px;
border: 1px solid var(--line, #d6e4ff);
border-radius: 12px;
background: var(--chip, #f7faff);
color: var(--text, #0b1f3a);
font: inherit;
font-size: 15px;
cursor: pointer;
}
.card-icon {
width: 44px;
height: 44px;
background-color: var(--c, #1677ff);
-webkit-mask: var(--icon) center / contain no-repeat;
mask: var(--icon) center / contain no-repeat;
}
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
align-items: start;
}
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.wide { grid-column: 1 / -1; }
.form label, .form legend { font-size: 13px; color: var(--muted, #5b6b85); }
.form input, .form select, .form textarea {
width: 100%;
box-sizing: border-box;
min-height: 36px;
border: 1px solid var(--line, #d6e4ff);
border-radius: 8px;
padding: 6px 10px;
font: inherit;
background: #fff;
}
.form select {
appearance: none;
-webkit-appearance: none;
padding-right: 34px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%235b6b85' d='M2.2 4.2 6 8l3.8-3.8-.9-.9L6 6.2 3.1 3.3z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
}
.form input[type="checkbox"],
.form input[type="radio"],
.form input[type="file"] {
width: auto;
min-height: 0;
padding: 0;
border: 0;
background: transparent;
}
.split { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.choices {
margin: 0;
padding: 12px 14px 14px;
border: 1px solid var(--line, #d6e4ff);
border-radius: 8px;
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
}
.choices legend { padding: 0 6px; }
.check {
display: inline-flex;
align-items: center;
gap: 6px;
width: auto;
color: var(--text, #0b1f3a);
font-size: 14px;
}
.check input { width: 16px; height: 16px; margin: 0; flex: none; accent-color: var(--c, #1677ff); }
.files { margin: 0; padding-left: 18px; color: var(--text, #0b1f3a); }
.back, .submit {
height: 34px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: var(--c, #1677ff);
color: #fff;
cursor: pointer;
}
.back {
display: inline-flex;
align-items: center;
gap: 2px;
justify-self: start;
width: fit-content;
background: transparent;
color: var(--c, #1677ff);
padding: 0;
}
.back svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.submit { justify-self: end; width: fit-content; }
.error { color: #c45656; margin: 0; }
.page-title { margin: 0; font-size: 22px; font-weight: 650; }
@media (max-width: 860px) {
.cards { grid-template-columns: 1fr 1fr; }
}
@container (max-width: 680px) {
.form, .split { grid-template-columns: 1fr; }
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts">
await navigateTo("/library/policies", { replace: true })
</script>
+287
View File
@@ -0,0 +1,287 @@
<template>
<div>
<h2 class="page-title">政策分析</h2>
<div class="tabs" role="tablist" aria-label="政策类别">
<button
v-for="item in POLICY_KINDS"
:key="item.key"
type="button"
role="tab"
:aria-current="item.key === kind ? 'true' : undefined"
@click="setKind(item.key)"
>{{ item.label }}</button>
</div>
<form v-if="kind === 'all'" class="policy-search" role="search" @submit.prevent>
<input
v-model="keyword"
type="search"
placeholder="检索标题或正文"
aria-label="检索政策"
autocomplete="off"
@input="onSearchInput"
>
</form>
<p v-if="loading" class="hint">列表加载中…</p>
<template v-else-if="region">
<p v-if="region.placeholder" class="placeholder">{{ region.placeholder }}</p>
<template v-else>
<ul v-if="region.items?.length" class="card-list">
<li v-for="item in region.items" :key="item.id">
<NuxtLink class="row" :to="`/articles/${item.id}`">
<div
class="thumb"
:class="{ 'is-empty': !item.imageUrl }"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
:role="item.imageUrl ? 'img' : undefined"
:aria-label="item.imageUrl ? '封面' : '无封面'"
/>
<div class="meta">
<strong>{{ item.title }}</strong>
<div v-if="item.kindLabel" class="meta-sub">{{ item.kindLabel }}</div>
</div>
</NuxtLink>
</li>
</ul>
<nav v-if="Number(region.total || 0) > 0" class="pager" aria-label="列表分页">
<div class="pager-meta">共 {{ region.total }} 条 · 第 {{ region.current }}/{{ pageCount }} 页</div>
<div class="pager-controls">
<div class="pager-nav">
<button type="button" :disabled="region.current <= 1" @click="goPage(region.current - 1)">上一页</button>
<template v-for="(item, index) in visiblePages" :key="`${item}-${index}`">
<span v-if="item === 'ellipsis'" class="pager-ellipsis" aria-hidden="true">…</span>
<button
v-else
type="button"
:aria-current="item === region.current ? 'page' : undefined"
@click="goPage(item)"
>{{ item }}</button>
</template>
<button type="button" :disabled="region.current >= pageCount" @click="goPage(region.current + 1)">下一页</button>
</div>
<label class="pager-size">每页
<select :value="pageSize" aria-label="每页条数" @change="onSizeChange">
<option v-for="n in SIZE_OPTIONS" :key="n" :value="n">{{ n }} 条/页</option>
</select>
</label>
</div>
</nav>
</template>
</template>
</div>
</template>
<script setup lang="ts">
import { POLICY_KINDS } from "~/composables/useLibraryNav"
const SIZE_OPTIONS = [10, 20, 100] as const
const DEFAULT_SIZE = 10
const route = useRoute()
const router = useRouter()
const { get } = useSiteApi()
const region = ref<any>(null)
const loading = ref(true)
const keyword = ref(String(route.query.q || ""))
let searchTimer: ReturnType<typeof setTimeout> | undefined
const kind = computed(() => {
const raw = String(route.query.kind || "all")
return POLICY_KINDS.some((item) => item.key === raw) ? raw : "all"
})
const currentPage = computed(() => {
const raw = Number(route.query.page || 1)
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1
})
const pageSize = computed(() => {
const raw = Number(route.query.size || DEFAULT_SIZE)
return (SIZE_OPTIONS as readonly number[]).includes(raw) ? raw : DEFAULT_SIZE
})
const pageCount = computed(() => {
const total = Number(region.value?.total || 0)
const size = Number(region.value?.size || pageSize.value)
return Math.max(1, Math.ceil(total / size))
})
const visiblePages = computed(() => {
const total = pageCount.value
const current = Math.min(Math.max(1, Number(region.value?.current || 1)), total)
if (total <= 5) return Array.from({ length: total }, (_, i) => i + 1) as Array<number | "ellipsis">
const picked = new Set<number>([1, total, current])
for (let i = current - 1; i <= current + 1; i++) {
if (i >= 1 && i <= total) picked.add(i)
}
const sorted = [...picked].sort((a, b) => a - b)
const items: Array<number | "ellipsis"> = []
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i]! - sorted[i - 1]! > 1) items.push("ellipsis")
items.push(sorted[i]!)
}
return items
})
async function load() {
loading.value = true
try {
const kindQuery = kind.value === "all" ? "" : `&kind=${kind.value}`
const needle = kind.value === "all" ? String(route.query.q || "").trim() : ""
const qQuery = needle ? `&q=${encodeURIComponent(needle)}` : ""
region.value = await get<any>(
`/about/articles/policy_analyses?current=${currentPage.value}&size=${pageSize.value}${kindQuery}${qQuery}`,
)
} finally {
loading.value = false
}
}
function patchQuery(patch: Record<string, string | null>) {
const query: Record<string, string> = { ...route.query } as Record<string, string>
for (const [key, value] of Object.entries(patch)) {
if (value === null || value === "") delete query[key]
else query[key] = value
}
router.push({ query })
}
function setKind(next: string) {
patchQuery({
kind: next === "all" ? null : next,
page: null,
q: next === "all" ? (keyword.value.trim() || null) : null,
})
}
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
patchQuery({
q: keyword.value.trim() ? keyword.value.trim() : null,
page: null,
kind: null,
})
}, 200)
}
function goPage(page: number) {
patchQuery({
page: page <= 1 ? null : String(page),
size: pageSize.value === DEFAULT_SIZE ? null : String(pageSize.value),
kind: kind.value === "all" ? null : kind.value,
q: kind.value === "all" && keyword.value.trim() ? keyword.value.trim() : null,
})
}
function onSizeChange(event: Event) {
const value = Number((event.target as HTMLSelectElement).value)
const size = (SIZE_OPTIONS as readonly number[]).includes(value) ? value : DEFAULT_SIZE
patchQuery({
page: null,
size: size === DEFAULT_SIZE ? null : String(size),
kind: kind.value === "all" ? null : kind.value,
q: kind.value === "all" && keyword.value.trim() ? keyword.value.trim() : null,
})
}
watch(
() => route.query.q,
(value) => {
const next = String(value || "")
if (next !== keyword.value) keyword.value = next
},
)
watch(
() => [kind.value, currentPage.value, pageSize.value, String(route.query.q || "")] as const,
() => {
void load()
},
{ immediate: true },
)
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
</script>
<style scoped>
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.tabs {
display: flex; flex-wrap: nowrap; gap: 0; margin: 0 0 16px;
overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
border-bottom: 1px solid var(--line, #d6e4ff);
}
.tabs button {
flex: none; appearance: none; border: 0; background: transparent; cursor: pointer;
padding: 10px 14px; font: inherit; font-size: 14px; color: var(--muted, #5b6b85);
border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap;
}
.tabs button[aria-current="true"] {
color: var(--c, #1677ff); font-weight: 600; border-bottom-color: var(--c, #1677ff);
}
.policy-search { margin: 0 0 16px; }
.policy-search input {
width: 100%; height: 38px; padding: 0 12px;
border: 1px solid var(--line, #d6e4ff); border-radius: 8px;
background: var(--surface, #fff); color: var(--text, #0b1f3a);
font: inherit; font-size: 14px;
}
.placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
border: 1px dashed var(--line, #d6e4ff);
}
.hint { margin: 12px 0 0; text-align: center; color: var(--muted, #5b6b85); font-size: 14px; }
.card-list { margin: 0; padding: 0; list-style: none; }
.card-list li { border-bottom: 1px solid var(--line, #d6e4ff); }
.card-list li:last-child { border-bottom: 0; }
.card-list .row {
display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 16px;
align-items: center; padding: 14px 0; color: inherit; text-decoration: none;
}
.card-list .thumb {
width: 112px; height: 72px; border-radius: 8px;
background: var(--chip, #f7faff) center/cover no-repeat;
border: 1px solid var(--line, #d6e4ff);
}
.card-list .thumb.is-empty {
background: var(--chip, #f7faff) url("/cover-empty.png") center / contain no-repeat;
border-color: transparent;
}
.card-list .meta { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.card-list strong { font-size: 15px; font-weight: 600; line-height: 1.45; }
.card-list .meta-sub { font-size: 12px; color: var(--muted, #5b6b85); }
.card-list .row:hover strong { color: var(--c, #1677ff); }
.pager {
display: flex; flex-direction: column; align-items: center; gap: 10px;
margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--line, #d6e4ff);
}
.pager-meta { font-size: 12px; color: var(--muted, #5b6b85); }
.pager-controls {
display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px 14px; width: 100%;
}
.pager-nav { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
.pager button, .pager select {
min-width: 36px; height: 34px; padding: 0 12px;
border: 1px solid var(--line, #d6e4ff); background: var(--surface, #fff);
color: var(--text, #0b1f3a); border-radius: 8px; font: inherit;
}
.pager button[aria-current="page"] {
background: var(--c, #1677ff); border-color: var(--c, #1677ff); color: #fff;
}
.pager button:disabled { opacity: 0.4; cursor: not-allowed; }
.pager-size {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--muted, #5b6b85);
}
.pager-ellipsis {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 34px; color: var(--muted, #5b6b85);
}
@media (max-width: 860px) {
.card-list .row { grid-template-columns: 88px minmax(0, 1fr); gap: 12px; }
.card-list .thumb { width: 88px; height: 58px; }
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<section class="panel">
<h1>会员登录</h1>
<p>登录须勾选同意隐私政策。账号接口尚未接入,此页只区分公开访问与登录入口。</p>
<label>
<input v-model="agreed" type="checkbox" />
我已阅读并同意隐私政策
</label>
</section>
</template>
<script setup lang="ts">
const agreed = ref(false)
</script>
<style scoped>
.panel {
max-width: 720px;
background: var(--color-surface);
padding: 40px;
}
h1 {
margin: 0 0 16px;
font-weight: 500;
}
p,
label {
line-height: 1.8;
color: var(--color-muted);
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout">
<nav class="side-nav" aria-label="行业党建子导航">
<NuxtLink
v-for="section in PARTY_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel">
<NuxtPage />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { PARTY_SECTIONS } from "~/composables/usePartyNav"
definePageMeta({ layout: false })
const route = useRoute()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => {
const path = route.path
if (path.startsWith("/party/activities")) return "activities"
return "branch"
})
const crumb = computed(() => {
const section = PARTY_SECTIONS.find((item) => item.key === activeKey.value)
return `行业党建 / ${section?.label || "支部介绍"}`
})
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<template>
<AboutArticleList title="行业党建" category="party_activities" show-cover />
</template>
+38
View File
@@ -0,0 +1,38 @@
<template>
<div>
<h2 class="page-title">协会党支部介绍</h2>
<p v-if="loading" class="hint">正文加载中…</p>
<p v-else-if="page?.placeholder" class="placeholder">{{ page.placeholder }}</p>
<template v-else-if="page">
<div class="prose" v-html="page.bodyHtml" />
<AboutAttachments :files="page.attachments" />
</template>
</div>
</template>
<script setup lang="ts">
const { get } = useSiteApi()
const page = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try {
page.value = await get<any>("/about/pages/party_branch")
} finally {
loading.value = false
}
})
</script>
<style scoped>
.page-title { margin: 0 0 18px; font-size: 22px; font-weight: 650; letter-spacing: 0.08em; }
.hint, .placeholder {
margin: 0; padding: 48px 20px; text-align: center;
color: var(--muted, #5b6b85); font-size: 15px;
}
.placeholder { border: 1px dashed var(--line, #d6e4ff); }
.prose { font-size: 15px; line-height: 1.85; }
.prose :deep(p) { margin: 0 0 1em; }
.prose :deep(h3) { margin: 1.4em 0 0.6em; font-size: 17px; }
.prose :deep(ul) { margin: 0 0 1em; padding-left: 1.2em; }
</style>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts">
await navigateTo("/party/branch", { replace: true })
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout">
<nav class="side-nav" aria-label="报告核验">
<NuxtLink
v-for="section in REPORT_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel" :class="{ 'is-chat': activeKey === 'review' }">
<NuxtPage />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { REPORT_SECTIONS } from "~/composables/useReportNav"
definePageMeta({ layout: false })
const route = useRoute()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => (route.path.startsWith("/reports/review") ? "review" : "query"))
const crumb = computed(() => {
const section = REPORT_SECTIONS.find((item) => item.key === activeKey.value)
return `报告核验 / ${section?.label || "报告查询"}`
})
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
.main-panel.is-chat {
padding: 0;
overflow: hidden;
min-height: 640px;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts">
await navigateTo("/reports/query", { replace: true })
</script>
+90
View File
@@ -0,0 +1,90 @@
<template>
<section aria-label="报告查询">
<h2>报告查询</h2>
<p class="notice">以出具机构数据为准,协会结果仅供参考。</p>
<form @submit.prevent="search">
<label for="report-number">报告编号</label>
<div class="row">
<input id="report-number" v-model="number" maxlength="64" placeholder="填写报告上的编号" autocomplete="off">
<button type="submit" class="btn" :disabled="busy">查询</button>
</div>
</form>
<p v-if="error" class="error">{{ error }}</p>
<div v-else-if="result" class="result">
<dl v-if="result.found">
<div>
<dt>出具机构</dt>
<dd>{{ result.institutionName }}</dd>
</div>
<div>
<dt>报告编号</dt>
<dd>{{ result.reportNumber }}</dd>
</div>
<div>
<dt>出具日期</dt>
<dd>{{ result.issuedOn }}</dd>
</div>
</dl>
<p>{{ result.notice }}</p>
</div>
</section>
</template>
<script setup lang="ts">
type QueryResult = {
found: boolean
notice: string
institutionName?: string
reportNumber?: string
issuedOn?: string
}
const { get } = useSiteApi()
const number = ref("")
const busy = ref(false)
const error = ref("")
const result = ref<QueryResult | null>(null)
async function search() {
error.value = ""
result.value = null
const value = number.value.trim()
if (!value) {
error.value = "请填写报告编号"
return
}
busy.value = true
try {
result.value = await get<QueryResult>(`/reports/query?number=${encodeURIComponent(value)}`)
} catch (exc: any) {
error.value = exc?.data?.detail || "查询失败,请稍后再试"
} finally {
busy.value = false
}
}
</script>
<style scoped>
h2 { margin: 0 0 8px; font-size: 22px; font-weight: 650; letter-spacing: 0.06em; }
.notice, .result p { margin: 0 0 16px; font-size: 14px; line-height: 1.7; color: var(--muted, #5b6b85); }
label { display: block; margin: 0 0 6px; font-size: 13px; color: var(--muted, #5b6b85); }
.row { display: flex; gap: 10px; align-items: center; }
input {
flex: 1; min-width: 0; padding: 10px 12px;
border: 1px solid var(--line, #d6e4ff); border-radius: 8px;
background: var(--surface, #fff); color: var(--text, #0b1f3a); font: inherit;
}
.btn {
appearance: none; border: 0; border-radius: 8px; padding: 10px 16px;
font: inherit; background: var(--c, #1677ff); color: #fff; cursor: pointer;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.error { margin: 12px 0 0; color: #b91c1c; font-size: 14px; }
dl { margin: 8px 0 12px; }
dl div { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--line, #d6e4ff); }
dt { color: var(--muted, #5b6b85); font-size: 13px; }
dd { margin: 0; font-size: 14px; }
@media (max-width: 860px) {
.row { flex-direction: column; align-items: stretch; }
}
</style>
+222
View File
@@ -0,0 +1,222 @@
<template>
<section class="chat" aria-label="报告审核">
<header class="chat-head">
<div class="brand">
<span class="mark" aria-hidden="true">审</span>
<div>
<h2>报告审核</h2>
<p>选择报告文件并提问。当前为演示,不会保存,也不会生成审核结论。</p>
</div>
</div>
<div class="head-actions">
<span class="status">演示 · 尚未开通</span>
<button type="button" class="text-btn" @click="resetChat">清空对话</button>
</div>
</header>
<div ref="scroller" class="thread" role="log" aria-live="polite">
<article v-for="item in messages" :key="item.id" class="row" :class="item.role">
<span v-if="item.role === 'assistant'" class="avatar" aria-hidden="true">协</span>
<div class="bubble"><p>{{ item.text }}</p></div>
</article>
<div v-if="showSuggestions" class="suggestions">
<p>可以先试这些问题</p>
<div>
<button v-for="item in SUGGESTIONS" :key="item" type="button" @click="send(item)">{{ item }}</button>
</div>
</div>
</div>
<form class="composer" @submit.prevent="send()">
<div class="file-row">
<label class="file-btn">
选择文件
<input type="file" :disabled="replying" @change="onFile">
</label>
<span v-if="fileName" class="file-name">{{ fileName }}</span>
<button v-if="fileName" type="button" class="text-btn" @click="clearFile">移除</button>
</div>
<label class="sr-only" for="review-draft">输入问题</label>
<textarea
id="review-draft"
ref="draftEl"
v-model="draft"
rows="1"
maxlength="500"
placeholder="例如:建筑面积前后是否一致?"
@input="resizeDraft"
@keydown.enter.exact.prevent="send()"
/>
<button type="submit" :disabled="!canSend">提交</button>
<p class="composer-note">提交后只提示功能尚未开通。文件和问题都留在这台浏览器里,不会上传。</p>
</form>
</section>
</template>
<script setup lang="ts">
const WELCOME = "您好。可以选择一份报告文件,再提出想核对的问题。当前为演示,功能尚未开通,不会保存内容,也不会生成审核结论。"
const NOTICE = "功能尚未开通。提交内容不会被保存,也不会生成审核结论。"
const SUGGESTIONS = [
"报告中的建筑面积前后是否一致?",
"估价时点与报告日期是否一致?",
"文字里有没有明显错别字?",
]
type ChatMessage = { id: number; role: "assistant" | "user"; text: string }
const messages = ref<ChatMessage[]>([{ id: 1, role: "assistant", text: WELCOME }])
const draft = ref("")
const fileName = ref("")
const replying = ref(false)
const scroller = ref<HTMLElement | null>(null)
const draftEl = ref<HTMLTextAreaElement | null>(null)
const fileEl = ref<HTMLInputElement | null>(null)
let seq = 1
const showSuggestions = computed(() => messages.value.length === 1 && !replying.value)
const canSend = computed(() => draft.value.trim().length > 0 && !replying.value)
function nextId() {
seq += 1
return seq
}
async function scrollToEnd() {
await nextTick()
const el = scroller.value
if (el) el.scrollTop = el.scrollHeight
}
function resizeDraft() {
const el = draftEl.value
if (!el) return
el.style.height = "auto"
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}
function onFile(event: Event) {
const input = event.target as HTMLInputElement
fileEl.value = input
fileName.value = input.files?.[0]?.name || ""
}
function clearFile() {
fileName.value = ""
if (fileEl.value) fileEl.value.value = ""
}
async function send(text?: string) {
const content = (text ?? draft.value).trim()
if (!content || replying.value) return
const attached = fileName.value
draft.value = ""
clearFile()
await nextTick()
resizeDraft()
const line = attached ? `${content}\n附件:${attached}` : content
messages.value.push({ id: nextId(), role: "user", text: line })
messages.value.push({ id: nextId(), role: "assistant", text: NOTICE })
await scrollToEnd()
}
async function resetChat() {
replying.value = false
draft.value = ""
clearFile()
messages.value = [{ id: nextId(), role: "assistant", text: WELCOME }]
await nextTick()
resizeDraft()
await scrollToEnd()
}
</script>
<style scoped>
.chat {
display: flex;
flex-direction: column;
min-height: 640px;
height: min(720px, calc(100vh - 220px));
}
.chat-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--line, #d6e4ff);
}
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.mark {
display: grid; place-items: center; width: 40px; height: 40px; flex: none;
color: #fff; background: var(--c, #1677ff); border-radius: 12px; font-weight: 650;
}
.chat-head h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: 0.06em; }
.chat-head p { margin: 4px 0 0; font-size: 13px; color: var(--muted, #5b6b85); }
.head-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; flex: none; }
.status {
padding: 4px 10px; border-radius: 999px; font-size: 12px;
background: color-mix(in srgb, var(--c, #1677ff) 10%, var(--surface, #fff));
color: var(--c, #1677ff);
}
.text-btn {
appearance: none; border: 0; background: transparent; padding: 0;
color: var(--muted, #5b6b85); font: inherit; font-size: 13px; cursor: pointer;
}
.thread { flex: 1; min-height: 0; overflow-y: auto; padding: 22px 22px 8px; }
.row { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 16px; }
.row.user { justify-content: flex-end; }
.avatar {
display: grid; place-items: center; width: 32px; height: 32px; flex: none; border-radius: 50%;
background: color-mix(in srgb, var(--c, #1677ff) 14%, var(--surface, #fff));
color: var(--c, #1677ff); font-size: 13px; font-weight: 650;
}
.bubble {
max-width: min(78%, 560px); padding: 12px 14px;
border-radius: 4px 14px 14px 14px;
background: color-mix(in srgb, var(--c, #1677ff) 7%, var(--surface, #fff));
border: 1px solid var(--line, #d6e4ff);
}
.bubble p { margin: 0; font-size: 14px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.row.user .bubble {
border-radius: 14px 4px 14px 14px; background: var(--c, #1677ff); border-color: var(--c, #1677ff); color: #fff;
}
.suggestions { margin: 4px 0 18px 42px; }
.suggestions p { margin: 0 0 8px; font-size: 12px; color: var(--muted, #5b6b85); }
.suggestions div { display: flex; flex-wrap: wrap; gap: 8px; }
.suggestions button {
appearance: none; max-width: 100%; padding: 8px 12px; border: 1px solid var(--line, #d6e4ff);
border-radius: 999px; background: var(--surface, #fff); color: var(--text, #0b1f3a);
font: inherit; font-size: 13px; text-align: left; cursor: pointer;
}
.composer {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 12px;
padding: 12px 22px 16px; border-top: 1px solid var(--line, #d6e4ff);
}
.file-row { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.file-btn {
position: relative; overflow: hidden; padding: 6px 12px; border-radius: 999px;
border: 1px solid var(--line, #d6e4ff); font-size: 13px; cursor: pointer;
}
.file-btn input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.file-name { font-size: 13px; color: var(--muted, #5b6b85); }
.composer textarea {
width: 100%; min-height: 44px; max-height: 120px; resize: none; padding: 11px 12px;
border: 1px solid var(--line, #d6e4ff); border-radius: 12px; background: var(--surface, #fff);
color: var(--text, #0b1f3a); font: inherit; font-size: 14px; line-height: 1.5;
}
.composer button[type="submit"] {
align-self: end; height: 44px; min-width: 76px; padding: 0 16px; border: 0; border-radius: 12px;
background: var(--c, #1677ff); color: #fff; font: inherit; font-size: 14px; cursor: pointer;
}
.composer button[type="submit"]:disabled { opacity: 0.45; cursor: not-allowed; }
.composer-note { grid-column: 1 / -1; margin: 0; font-size: 12px; line-height: 1.5; color: var(--muted, #5b6b85); }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
@media (max-width: 860px) {
.chat { height: auto; min-height: 560px; }
.chat-head, .thread, .composer { padding-left: 16px; padding-right: 16px; }
.suggestions { margin-left: 0; }
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p v-if="loading" class="panel detail">正文加载中…</p>
<article v-else-if="detail" class="panel detail">
<h2>{{ detail.title }}</h2>
<div v-html="detail.bodyHtml" />
<section v-if="detail.attachments?.length" class="files">
<h3>附件下载</h3>
<ul>
<li v-for="file in detail.attachments" :key="file.url">
<a :href="file.url" :download="file.filename" rel="noopener">{{ file.filename }}</a>
</li>
</ul>
</section>
</article>
<p v-else class="panel detail">内容不存在或尚未发布</p>
</SiteHome>
</template>
<script setup lang="ts">
definePageMeta({ layout: false })
const route = useRoute()
const { get } = useSiteApi()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const detail = ref<any>(null)
const loading = ref(true)
let ticket = 0
async function load() {
const current = ++ticket
loading.value = true
detail.value = null
await ensureChrome()
try {
const slide = await get<any>(`/home/slides/${route.params.id}`).catch(() => null)
if (current !== ticket) return
detail.value = slide
} finally {
if (current === ticket) loading.value = false
}
}
onMounted(load)
watch(() => route.params.id, (id, previous) => {
if (previous !== undefined && id !== previous) load()
})
</script>
<style scoped>
.detail {
margin-top: 24px;
padding: 24px;
}
h2 {
margin: 0 0 16px;
}
.files {
margin-top: 28px;
padding-top: 16px;
border-top: 1px solid var(--line, #e5e7eb);
}
.files h3 {
margin: 0 0 10px;
font-size: 16px;
}
.files ul {
margin: 0;
padding: 0;
list-style: none;
}
.files li + li {
margin-top: 8px;
}
.files a {
color: var(--c, #1677ff);
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<template>
<SiteLoading v-if="!home" hint="正在读取内容" />
<SiteHome v-else :home="home" :logo-url="logoUrl">
<p class="crumb">首页 / {{ crumb }}</p>
<div class="layout">
<nav class="side-nav" aria-label="技术交流子导航">
<NuxtLink
v-for="section in TECH_SECTIONS"
:key="section.key"
:to="section.path"
:aria-current="section.key === activeKey ? 'page' : undefined"
>{{ section.label }}</NuxtLink>
</nav>
<div class="main-panel">
<NuxtPage />
</div>
</div>
</SiteHome>
</template>
<script setup lang="ts">
import { TECH_SECTIONS } from "~/composables/useTechNav"
definePageMeta({ layout: false })
const route = useRoute()
const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => {
const path = route.path
if (path.startsWith("/tech/exchange")) return "exchange"
return "seminars"
})
const crumb = computed(() => {
const section = TECH_SECTIONS.find((item) => item.key === activeKey.value)
return `技术交流 / ${section?.label || "技术研讨"}`
})
await ensureChrome()
</script>
<style scoped>
.crumb {
margin: 28px 0 16px;
font-size: 13px;
color: var(--muted, #5b6b85);
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
margin: 0 0 40px;
}
.side-nav {
align-self: start;
padding: 12px 0;
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
}
.side-nav a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: var(--muted, #5b6b85);
border-left: 3px solid transparent;
text-decoration: none;
}
.side-nav a[aria-current="page"] {
color: var(--c, #1677ff);
font-weight: 600;
background: var(--chip, #f7faff);
border-left-color: var(--c, #1677ff);
}
.main-panel {
background: var(--surface, #fff);
border: 1px solid var(--line, #d6e4ff);
border-radius: 14px;
padding: 22px 24px 28px;
min-height: 320px;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
.side-nav {
display: flex;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.side-nav a {
flex: none;
white-space: nowrap;
border-left: 0;
border-bottom: 2px solid transparent;
}
.side-nav a[aria-current="page"] {
border-bottom-color: var(--c, #1677ff);
background: transparent;
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<template>
<AboutArticleList title="技术交流" category="tech_exchange" show-cover />
</template>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts">
await navigateTo("/tech/seminars", { replace: true })
</script>
+3
View File
@@ -0,0 +1,3 @@
<template>
<AboutArticleList title="技术研讨" category="tech_seminars" show-cover />
</template>
+1
View File
@@ -0,0 +1 @@
/* formal: 默认主题。solemn 的变量在 assets/themes.css。两个目录只放外观,不放栏目页面。 */
+1
View File
@@ -0,0 +1 @@
/* 庄重风格变量已在 assets/themes.css 的 [data-theme="solemn"]。切换开关待协会确认后再接到后台配置。 */