继续教育新增可配置的协会教育入口 #3

Merged
cykj merged 1 commits from codex/issue-2-association-education into main 2026-11-02 04:00:40 +00:00
14 changed files with 490 additions and 4 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# 设计
沿用门户 edu.vue 布局、platforms.vue 卡片边框与主题变量,后台沿用 Element Plus 标签页和输入框。卡片复用后台配置的协会 LOGO,沿用现有图片+名称样式。
用户明确确认的原型:
- 门户 v2:https://www.figma.com/design/orfpx89Thcj7WO3hpqC8Si?node-id=5-316
- 后台配置:https://www.figma.com/design/orfpx89Thcj7WO3hpqC8Si?node-id=7-20
独立站点配置键 association_education,JSON 保存 webUrl、mobileUrl,默认均为空。后台 GET/PUT /api/admin/edu/association,公开 GET /api/site/edu/association。写入要求有效工作人员与 edu:edit;读取后台要求有效工作人员和继续教育菜单。URL 仅接受完整 HTTP(S) 地址,允许本地开发地址,不将工单中的 localhost 固化为生产默认值。不请求目标地址。两个字段整体校验后原子保存。
门户 /edu/association:两个卡片始终存在,空地址显示暂未开放,无链接;加载与失败状态单独显示,允许重试;有效地址新窗口打开并设置 noopener noreferrer。
测试使用独立 SQLite 数据库验证配置持久化与鉴权。浏览器联调时新增接口使用同一业务路由与隔离 SQLite,既有接口转发到原服务;测试配置不写入共享 MySQL。构建在隔离 worktree 执行。验证结果与限制见 tasks.md。
@@ -0,0 +1,5 @@
# 协会教育入口
来源:https://gitea.aichengyan.com/cykj/gsfdc/issues/2 (2026-11-02 读取正文及全部评论,0 条评论,无图片)。
在继续教育下新增协会教育,固定提供 Web 端、移动端两个外链卡片;工作人员在现有后台继续教育页面配置地址。影响 portal、admin、server;不修改 edu 或教育平台。
+8
View File
@@ -0,0 +1,8 @@
# 验收条件
- 继续教育保留在线教育、教育公告,增加协会教育;导航选中和面包屑正确。
- 协会教育固定显示 Web、移动端两个入口,响应式布局;可分别配置/清空地址。
- 保存后重新读取可获得相同配置,门户跳转使用后台配置值,原在线教育数据不受影响。
- 未配置不可跳转;加载失败可重试,保存失败保留输入。
- 非 HTTP(S)、缺少主机、携带账号密码、非法端口和控制字符的地址被拒绝,拒绝时不部分保存。
- 未登录、会员、停用工作人员、没有 edu:edit 的工作人员不能写入。
+22
View File
@@ -0,0 +1,22 @@
- [x] 工程接入、工单全文及占用检查
- [x] 原型视觉核对与确认(门户 v2、后台配置均获用户明确批准)
- [x] 后端配置与鉴权
- [x] 门户入口与后台配置表单
- [x] 任务范围测试、打包与视觉验证
- [x] 独立代码审查
- [ ] 远端 PR、集成验证及工单收尾(待授权及检查缺口处理)
## 验证记录(2026-11-02)
- `server`: `python -m pytest tests/test_association_education.py -q`,25 项通过。覆盖工作人员及角色权限、默认空值、保存/清空、非法地址、整体拒绝与原数据隔离。
- 全量 `DATABASE_URL='' python -m pytest -q`:37 项通过、51 项因未连接共享数据库而跳过;跳过项不计为验收通过。
- `portal`: `npm run build` 通过。
- `admin`: `VITE_BASE_URL=/ vite build` 通过;本次三个改动文件 ESLint 通过。
- 后台完整 `npm run build` 被 32 项原有 TypeScript 错误阻止,与主工作目录基线输出逐项相同。全量 ESLint 存在历史格式问题;基线 129 项,本任务首次检查 126 项,其中新增的控制字符正则规则问题已修复,其他均为既有格式问题。不能据此声称完整检查通过。
- 实际浏览器验证:保存 Web/移动地址、刷新后回显、非法协议拒绝且原配置保持、门户读取新配置、两个入口在新窗口打开对应地址。桌面及 390×844 手机视口视觉检查通过,无横向页面溢出;原在线教育和教育公告保持。
- 新增配置联调使用隔离 SQLite,未验证 MySQL 写入;未进行生产部署或教育平台内部业务验收。
- 独立审查由 `review_issue2` 完成,未发现阻塞代码问题;提出的构建和浏览器验证已补齐,MySQL 验证缺口保留。
- 工单交付前重读正文及全部评论:仍开放,0 条评论,需求无新增。
- 联调结束后已恢复原主工作目录的门户 13000、后台 13006、后端 18000;核对三个进程工作目录及 HTTP 响应,释放共享测试环境占用。任务实现保存在 worktree,尚未替换主版本。
本地证据位于主工作目录 `.local/agent/tasks/issue-2/`:`portal-build.log`、`admin-vite-build.log`、`admin-build.log`、`admin-baseline-types.log`、`admin-lint.log`、`admin-baseline-lint.log`、`portal-actual.jpg`、`portal-mobile.jpg`、`admin-actual.jpg`。不包含账号密码。
+13
View File
@@ -75,3 +75,16 @@ export function saveRelatedLinks(data: Record<string, unknown>) {
export function saveCompliance(data: Record<string, unknown>) {
return request.put({ url: '/api/admin/home/compliance', data })
}
export interface AssociationEducationLinks {
webUrl: string
mobileUrl: string
}
export function fetchAssociationEducation() {
return request.get<AssociationEducationLinks>({ url: '/api/admin/edu/association' })
}
export function saveAssociationEducation(data: AssociationEducationLinks) {
return request.put({ url: '/api/admin/edu/association', data })
}
@@ -0,0 +1,102 @@
<template>
<div v-loading="loading" class="min-h-40">
<ElAlert v-if="failed" title="暂时无法读取协会教育配置" type="error" :closable="false">
<ElButton link type="primary" @click="load">重试</ElButton>
</ElAlert>
<template v-else-if="ready">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<p class="m-0 text-sm text-g-600">配置协会教育平台入口,门户点击后在新窗口打开。</p>
<ElButton v-if="canEdit" type="primary" :loading="saving" @click="save">保存</ElButton>
</div>
<ElForm
ref="formRef"
:model="links"
:rules="rules"
label-position="top"
:disabled="!canEdit || saving"
class="association-form"
>
<ElFormItem label="Web 端地址" prop="webUrl">
<ElInput v-model="links.webUrl" placeholder="https://" maxlength="2048" clearable />
</ElFormItem>
<ElFormItem label="移动端地址" prop="mobileUrl">
<ElInput v-model="links.mobileUrl" placeholder="https://" maxlength="2048" clearable />
</ElFormItem>
</ElForm>
<p class="m-0 text-sm text-g-600">留空时,门户对应入口显示“暂未开放”。</p>
</template>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { fetchAssociationEducation, saveAssociationEducation } from '@/api/home'
import { useAuth } from '@/hooks/core/useAuth'
const { hasAuth } = useAuth()
const canEdit = computed(() => hasAuth('edu:edit'))
const formRef = ref<FormInstance>()
const links = reactive({ webUrl: '', mobileUrl: '' })
const loading = ref(false)
const saving = ref(false)
const ready = ref(false)
const failed = ref(false)
const validateUrl = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
const text = value.trim()
if (!text) return callback()
try {
const url = new URL(text)
if (
!/^https?:\/\//i.test(text) ||
!url.hostname ||
url.username ||
url.password ||
/[\s\\]/u.test(text) ||
Array.from(text).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) ||
url.port === '0'
)
throw new Error()
callback()
} catch {
callback(new Error('请填写完整的 http:// 或 https:// 网址,不包含账号密码'))
}
}
const rules: FormRules = {
webUrl: [{ validator: validateUrl, trigger: 'blur' }],
mobileUrl: [{ validator: validateUrl, trigger: 'blur' }]
}
const load = async () => {
loading.value = true
failed.value = false
try {
Object.assign(links, await fetchAssociationEducation())
ready.value = true
} catch {
failed.value = true
} finally {
loading.value = false
}
}
const save = async () => {
if (!canEdit.value || saving.value || !(await formRef.value?.validate().catch(() => false)))
return
saving.value = true
try {
const data = { webUrl: links.webUrl.trim(), mobileUrl: links.mobileUrl.trim() }
await saveAssociationEducation(data)
Object.assign(links, data)
ElMessage.success('已保存')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.association-form {
max-width: 600px;
}
</style>
+19 -4
View File
@@ -3,8 +3,12 @@
<ElTabs v-if="ready" v-model="activeTab" type="border-card">
<ElTabPane label="在线教育" name="platforms">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<p class="m-0 text-sm text-g-600">每张卡片一张图,图下是教育网站名称。门户点击后在新窗口打开网址。</p>
<ElButton v-auth="'edu:edit'" type="primary" :loading="saving" @click="save">保存</ElButton>
<p class="m-0 text-sm text-g-600"
>每张卡片一张图,图下是教育网站名称。门户点击后在新窗口打开网址。</p
>
<ElButton v-auth="'edu:edit'" type="primary" :loading="saving" @click="save"
>保存</ElButton
>
</div>
<div class="media-grid">
<article v-for="(item, index) in items" :key="index" class="media-card">
@@ -14,7 +18,12 @@
@pick="(payload) => assignImage(item, payload)"
@clear="clearImage(item)"
/>
<ElInput v-model="item.name" class="mt-3" :disabled="!canEdit" placeholder="教育网站名称" />
<ElInput
v-model="item.name"
class="mt-3"
:disabled="!canEdit"
placeholder="教育网站名称"
/>
<ElInput v-model="item.url" class="mt-2" :disabled="!canEdit" placeholder="https://" />
<div class="mt-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
@@ -26,7 +35,9 @@
controls-position="right"
/>
</div>
<ElButton v-if="canEdit" link type="danger" @click="items.splice(index, 1)">删除</ElButton>
<ElButton v-if="canEdit" link type="danger" @click="items.splice(index, 1)"
>删除</ElButton
>
</div>
</article>
<button v-if="canEdit" type="button" class="media-add" @click="addItem">
@@ -43,11 +54,15 @@
enable-cover
/>
</ElTabPane>
<ElTabPane label="协会教育" name="association">
<AssociationEducation v-if="activeTab === 'association'" />
</ElTabPane>
</ElTabs>
</div>
</template>
<script setup lang="ts">
import AssociationEducation from './components/AssociationEducation.vue'
import AboutColumnEditor from '@/views/about/components/AboutColumnEditor.vue'
import { fetchEduPlatforms, saveEduPlatforms } from '@/api/home'
import { useAuth } from '@/hooks/core/useAuth'
+1
View File
@@ -1,4 +1,5 @@
export const EDU_SECTIONS = [
{ key: "platforms", label: "在线教育", path: "/edu/platforms" },
{ key: "notices", label: "教育公告", path: "/edu/notices" },
{ key: "association", label: "协会教育", path: "/edu/association" },
] as const
+1
View File
@@ -28,6 +28,7 @@ const { home, logoUrl, ensureChrome } = useSiteChrome()
const activeKey = computed(() => {
const path = route.path
if (path.startsWith("/edu/association")) return "association"
if (path.startsWith("/edu/notices")) return "notices"
return "platforms"
})
+112
View File
@@ -0,0 +1,112 @@
<template>
<div>
<h2 class="page-title">协会教育</h2>
<p v-if="loading" class="hint" role="status">列表加载中…</p>
<p v-else-if="failed" class="placeholder" role="alert">
暂时无法读取协会教育入口<button class="retry" type="button" @click="load">重试</button>
</p>
<ul v-else class="cards">
<li v-for="item in items" :key="item.key">
<component :is="item.url ? 'a' : 'div'" :href="item.url || undefined"
:target="item.url ? '_blank' : undefined" :rel="item.url ? 'noopener noreferrer' : undefined"
:class="{ unavailable: !item.url }">
<img v-if="logoUrl" :src="logoUrl" alt="协会标志" />
<div v-else class="card-image" aria-hidden="true" />
<span>{{ item.name }}</span>
<small v-if="!item.url">暂未开放</small>
</component>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const { get } = useSiteApi()
const { logoUrl } = useSiteChrome()
const loading = ref(true)
const failed = ref(false)
const links = ref({ webUrl: "", mobileUrl: "" })
const items = computed(() => [
{ key: "web", name: "协会教育(Web 端)", url: links.value.webUrl },
{ key: "mobile", name: "协会教育(移动端)", url: links.value.mobileUrl },
])
async function load() {
loading.value = true
failed.value = false
try {
links.value = await get<typeof links.value>("/edu/association")
} catch {
failed.value = true
} finally {
loading.value = false
}
}
await load()
</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,
.cards .unavailable {
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,
.card-image {
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);
}
.cards .unavailable { color: var(--muted, #5b6b85); }
.cards small { display: block; padding: 0 10px 12px; text-align: center; }
.cards a:focus-visible { outline: 2px solid var(--c, #1677ff); outline-offset: 3px; }
.retry { margin-left: 8px; border: 1px solid var(--line, #d6e4ff); border-radius: 4px; background: var(--surface, #fff); color: var(--c, #1677ff); padding: 4px 10px; cursor: pointer; }
</style>
+50
View File
@@ -32,3 +32,53 @@ def write_edu_platforms(body: EduPlatformsWrite, db: Session = Depends(get_db))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
class AssociationEducationWrite(BaseModel):
webUrl: str = Field(max_length=2048)
mobileUrl: str = Field(max_length=2048)
def _association_access(db: Session, principal: dict, *, write: bool = False) -> None:
from app.models.rbac import AdminUser
from app.services.rbac import granted_resources
try:
user_id = int(principal.get("sub", ""))
except (ValueError, TypeError):
raise HTTPException(status_code=401, detail="登录已失效") from None
user = db.get(AdminUser, user_id)
if user is None or user.status != "active":
raise HTTPException(status_code=401, detail="登录已失效")
resources = granted_resources(db, user_id)
allowed = any(
(item.type == "button" and item.permission == "edu:edit") if write
else (item.type == "menu" and item.path == "/edu")
for item in resources
)
if not allowed:
raise HTTPException(status_code=403, detail="没有继续教育操作权限")
@router.get("/edu/association")
def read_association(db: Session = Depends(get_db), principal: dict = Depends(require_staff)) -> dict:
from app.services.association_education import read_links
_association_access(db, principal)
return read_links(db)
@router.put("/edu/association")
def write_association(
body: AssociationEducationWrite,
db: Session = Depends(get_db),
principal: dict = Depends(require_staff),
) -> dict:
from app.services.association_education import save_links
_association_access(db, principal, write=True)
try:
save_links(db, body.webUrl, body.mobileUrl)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True}
+7
View File
@@ -10,3 +10,10 @@ router = APIRouter()
@router.get("/edu/platforms")
def read_edu_platforms(db: Session = Depends(get_db)) -> dict:
return public_edu_platforms(db)
@router.get("/edu/association")
def read_association(db: Session = Depends(get_db)) -> dict:
from app.services.association_education import read_links
return read_links(db)
@@ -0,0 +1,49 @@
"""协会教育只配置两个外链,不承载学习业务。"""
import json
from urllib.parse import urlsplit
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.content import SiteSetting
SETTING_KEY = "association_education"
def validate_url(value: str) -> str:
text = value.strip()
if not text:
return ""
try:
parsed = urlsplit(text)
valid = (
len(text) <= 2048
and parsed.scheme.lower() in {"http", "https"}
and bool(parsed.hostname)
and parsed.username is None
and parsed.password is None
and "\\" not in text
and not any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in text)
and (parsed.port is None or 1 <= parsed.port <= 65535)
)
except ValueError:
valid = False
if not valid:
raise ValueError("请填写完整的 http:// 或 https:// 网址,不包含账号密码")
return text
def read_links(db: Session) -> dict[str, str]:
row = db.scalar(select(SiteSetting).where(SiteSetting.key == SETTING_KEY))
stored = json.loads(row.value) if row else {}
return {key: stored.get(key, "") for key in ("webUrl", "mobileUrl")}
def save_links(db: Session, web_url: str, mobile_url: str) -> None:
value = json.dumps({"webUrl": validate_url(web_url), "mobileUrl": validate_url(mobile_url)})
row = db.scalar(select(SiteSetting).where(SiteSetting.key == SETTING_KEY))
if row is None:
db.add(SiteSetting(key=SETTING_KEY, value=value))
else:
row.value = value
db.commit()
@@ -0,0 +1,87 @@
"""配置持久化和鉴权使用独立 SQLite 数据库,不连接业务库。"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin.edu import router as admin_router
from app.api.site.edu import router as site_router
from app.core.config import get_settings
from app.core.security import create_access_token
from app.db import Base, get_db
from app.models.content import SiteSetting
from app.models.rbac import AdminUser, AdminRole, AdminResource, AdminUserRole, AdminRoleResource
from app.services.association_education import validate_url
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv('JWT_SECRET', 'test-secret-must-be-32-characters!')
get_settings.cache_clear()
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
Base.metadata.create_all(engine, tables=[m.__table__ for m in (SiteSetting, AdminUser, AdminRole, AdminResource, AdminUserRole, AdminRoleResource)])
with Session(engine) as db:
db.add_all([AdminUser(id=i, username=f'user{i}', password_hash='unused', display_name='测试', status='disabled' if i == 3 else 'active') for i in (1,2,3,4)])
db.add_all([AdminRole(id=1, code='editor', name='教育编辑'), AdminRole(id=2, code='reader', name='只读')])
db.add_all([AdminResource(id=1, type='menu', name='edu', title='继续教育', path='/edu'), AdminResource(id=2, type='button', name='edu:edit', title='编辑', permission='edu:edit')])
db.flush()
db.add_all([AdminUserRole(user_id=i, role_id=1) for i in (1,3)])
db.add_all([AdminRoleResource(role_id=1, resource_id=i) for i in (1,2)])
db.add(AdminUserRole(user_id=4, role_id=2))
db.add(AdminRoleResource(role_id=2, resource_id=1))
db.add(SiteSetting(key='unrelated',value='preserve'))
db.commit()
def database():
with Session(engine) as db:
yield db
app=FastAPI()
app.include_router(admin_router,prefix='/api/admin')
app.include_router(site_router,prefix='/api/site')
app.dependency_overrides[get_db]=database
with TestClient(app) as api:
yield api
with Session(engine) as db:
assert db.query(SiteSetting).filter_by(key='unrelated').one().value == 'preserve'
engine.dispose()
get_settings.cache_clear()
def headers(user=1,kind='staff'):
return {'Authorization':f'Bearer {create_access_token(str(user),kind)}'}
def test_round_trip_atomic_rejection_and_clear(client):
public='/api/site/edu/association'
admin='/api/admin/edu/association'
assert client.get(public).json()=={'webUrl':'','mobileUrl':''}
data={'webUrl':'http://127.0.0.1:9800','mobileUrl':'http://127.0.0.1:9801'}
assert client.put(admin,headers=headers(),json=data).status_code==200
assert client.get(public).json()==data
assert client.get(admin,headers=headers()).json()==data
assert client.put(admin,headers=headers(),json={'webUrl':'https://example.com/new','mobileUrl':'javascript:alert(1)'}).status_code==400
assert client.get(public).json()==data
assert client.put(admin,headers=headers(),json={'webUrl':'','mobileUrl':'https://example.com/m'}).status_code==200
assert client.get(public).json()=={'webUrl':'','mobileUrl':'https://example.com/m'}
@pytest.mark.parametrize('auth',[None,(1,'member'),(2,'staff'),(3,'staff'),(99,'staff')])
def test_authorization(client,auth):
kw={'headers':headers(*auth)} if auth else {}
assert client.get('/api/admin/edu/association',**kw).status_code in (401,403)
assert client.put('/api/admin/edu/association',json={'webUrl':'https://example.com','mobileUrl':''},**kw).status_code in (401,403)
assert client.get('/api/site/edu/association').json()=={'webUrl':'','mobileUrl':''}
@pytest.mark.parametrize('url',['javascript:alert(1)','//example.com','https://','https://a:99999','https://a:bad','https://user:password@example.com','https://a\\@b','https://a\nb','data:text/html,test','https://exa mple.com'])
def test_invalid_url(url):
with pytest.raises(ValueError):validate_url(url)
@pytest.mark.parametrize('url',['','https://example.com/path?q=中文#section','http://127.0.0.1:9800','https://[::1]:9801'])
def test_valid_url(url):
assert validate_url(url)==url
def test_read_only_staff_cannot_write(client):
assert client.get('/api/admin/edu/association',headers=headers(4)).status_code == 200
assert client.put('/api/admin/edu/association',headers=headers(4),json={'webUrl':'https://example.com','mobileUrl':''}).status_code == 403
@pytest.mark.parametrize('data',[{}, {'webUrl':''}, {'webUrl':'x'*2049,'mobileUrl':''}, {'webUrl':None,'mobileUrl':''}])
def test_invalid_payload_does_not_change_settings(client,data):
assert client.put('/api/admin/edu/association',headers=headers(),json=data).status_code == 422
assert client.get('/api/site/edu/association').json() == {'webUrl':'','mobileUrl':''}