chore: initialize GSREA website project
This commit is contained in:
commit
d3c5461de5
1842 files changed
+246140
No files matched your search
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
export const EDU_SECTIONS = [
|
||||
{ key: "platforms", label: "在线教育", path: "/edu/platforms" },
|
||||
{ key: "notices", label: "教育公告", path: "/edu/notices" },
|
||||
] as const
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
export const REPORT_SECTIONS = [
|
||||
{ key: "query", label: "报告查询", path: "/reports/query" },
|
||||
{ key: "review", label: "报告审核", path: "/reports/review" },
|
||||
] as const
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<AboutArticleList title="工作简报" category="work_briefs" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/about/intro", { replace: true })
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<AboutArticleList title="年度报告/工作计划" category="annual_reports" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<AboutArticleList title="结果公示" category="discipline_results" show-cover />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<AboutArticleList title="警示案例" category="warning_cases" show-cover />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/edu/platforms", { replace: true })
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<AboutArticleList title="教育公告" category="edu_notices" show-cover />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/expert/guides", { replace: true })
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/library/policies", { replace: true })
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<AboutArticleList title="行业党建" category="party_activities" show-cover />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/party/branch", { replace: true })
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/reports/query", { replace: true })
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<AboutArticleList title="技术交流" category="tech_exchange" show-cover />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/tech/seminars", { replace: true })
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<AboutArticleList title="技术研讨" category="tech_seminars" show-cover />
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
/* formal: 默认主题。solemn 的变量在 assets/themes.css。两个目录只放外观,不放栏目页面。 */
|
||||
@@ -0,0 +1 @@
|
||||
/* 庄重风格变量已在 assets/themes.css 的 [data-theme="solemn"]。切换开关待协会确认后再接到后台配置。 */
|
||||
Reference in new issue
Block a user