feat: 实现数据库维护功能

## 新增功能

### 1. 后端API路由 (lawrisk/api/v2.py)
- 添加了5个新的管理API端点:
  * GET /admin/regions - 获取地区列表
  * GET /admin/themes - 获取主题列表(按地区筛选)
  * GET /admin/permits - 获取许可列表(按地区和主题筛选)
  * GET /admin/permit-details - 获取许可详细信息
  * GET /admin/test - 测试路由

### 2. 前端管理界面 (static/db_admin.html)
- 实现了完整的数据库维护管理页面
- 4步操作流程:地区选择 → 主题列表 → 许可列表 → 详细信息展示
- 现代化UI设计,包括:
  * 渐变背景和响应式布局
  * 平滑动画过渡效果
  * 实时数据加载提示
  * 完整的许可信息展示(许可状态、经营范围、法律风险等)

## 技术实现
- RESTful API设计,返回标准JSON格式
- 直接从PostgreSQL数据库读取数据
- 所有API已通过curl和Flask测试客户端验证

## 测试结果
在端口8888上测试通过:
- admin/regions: 1个地区
- admin/themes: 57个主题
- admin/permits: 6个许可
- admin/permit-details: 完整许可信息和3个风险记录
- 静态页面: 成功加载

## 使用方法
```bash
# 启动服务
PORT=8888 python app.py &

# 访问管理界面
http://localhost:8888/static/db_admin.html

# API调用示例
curl http://localhost:8888/fs-ai-asistant/api/workflow/lawrisk/admin/regions
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Codex Agent 2025-10-30 08:52:48 +08:00
parent bfda66afc1
commit cbefb81a35
2 changed files with 756 additions and 2 deletions

View File

@ -6,7 +6,12 @@ from flask import Blueprint, jsonify, request
from concurrent.futures import ThreadPoolExecutor
from lawrisk.services.lawrisk_v2_service import search_v2, list_regions
from lawrisk.services.licensing_repo import list_permits_for_region
from lawrisk.services.licensing_repo import (
list_permits_for_region,
load_permits_and_risks,
list_region_theme_options,
load_theme_payload,
)
from lawrisk.services.lawrisk_service import suggest_questions_embed
v2_bp = Blueprint('lawrisk_v2', __name__, url_prefix='/fs-ai-asistant/api/workflow/lawrisk')
@ -58,7 +63,7 @@ def lawrisk_search_v2():
return jsonify({"success": False, "message": str(e), "data": {}}), 500
@v2_bp.get('/v2/regions')
@v2_bp.route('/v2/regions', methods=['GET'])
def lawrisk_regions():
"""Get list of available regions."""
try:
@ -125,3 +130,123 @@ def _extract_params():
region_value = payload.get("region") or payload.get("region_id")
return query, debug_flag, top_k_int, mode_value, region_value
@v2_bp.route('/admin/test', methods=['GET'])
def admin_test():
"""Simple test route."""
return jsonify({"success": True, "message": "Test route works!"})
@v2_bp.route('/test-simple', methods=['GET'])
def test_simple():
"""Very simple test."""
return jsonify({"status": "ok"})
@v2_bp.route('/admin/regions', methods=['GET'])
def admin_regions():
"""Get all regions for database maintenance."""
try:
regions = list_regions()
return jsonify({"success": True, "data": {"regions": regions}})
except Exception as exc:
print(f"admin_regions error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/themes', methods=['GET'])
def admin_themes():
"""Get themes for a specific region."""
region_value = request.args.get("region") or request.args.get("region_id")
if not region_value or (isinstance(region_value, str) and not region_value.strip()):
return jsonify({"success": False, "message": "region is required", "data": {}}), 400
region_token = region_value.strip() if isinstance(region_value, str) else str(region_value)
try:
catalog = list_region_theme_options()
region_id_lower = region_token.lower()
themes = []
seen_theme_ids = set()
for item in catalog:
if (item["region_id"] == region_token or
item["region_id"].lower() == region_id_lower or
item["region_name"].lower() == region_id_lower):
theme_id = item["theme_id"]
if theme_id not in seen_theme_ids:
seen_theme_ids.add(theme_id)
themes.append({
"id": theme_id,
"name": item["theme_name"],
"option_id": item["option_id"]
})
return jsonify({"success": True, "data": {"region": region_token, "themes": themes}})
except Exception as exc:
print(f"admin_themes error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/permits', methods=['GET'])
def admin_permits():
"""Get permits for a specific region-theme combination."""
region_value = request.args.get("region") or request.args.get("region_id")
theme_value = request.args.get("theme") or request.args.get("theme_id")
if not region_value or not theme_value:
return jsonify({"success": False, "message": "region and theme are required", "data": {}}), 400
region_token = region_value.strip() if isinstance(region_value, str) else str(region_value)
theme_token = theme_value.strip() if isinstance(theme_value, str) else str(theme_value)
try:
permits = load_permits_and_risks(region_token, theme_token)
return jsonify({
"success": True,
"data": {
"region": region_token,
"theme": theme_token,
"permits": permits
}
})
except Exception as exc:
print(f"admin_permits error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/permit-details', methods=['GET'])
def admin_permit_details():
"""Get detailed information for a specific permit."""
region_value = request.args.get("region") or request.args.get("region_id")
theme_value = request.args.get("theme") or request.args.get("theme_id")
permit_value = request.args.get("permit") or request.args.get("permit_id")
if not region_value or not theme_value or not permit_value:
return jsonify({"success": False, "message": "region, theme, and permit are required", "data": {}}), 400
region_token = region_value.strip() if isinstance(region_value, str) else str(region_value)
theme_token = theme_value.strip() if isinstance(theme_value, str) else str(theme_value)
permit_token = permit_value.strip() if isinstance(permit_value, str) else str(permit_value)
try:
permits = load_permits_and_risks(region_token, theme_token, permit_token)
if not permits:
return jsonify({"success": False, "message": "Permit not found", "data": {}}), 404
return jsonify({
"success": True,
"data": {
"region": region_token,
"theme": theme_token,
"permit": permits[0]
}
})
except Exception as exc:
print(f"admin_permit_details error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500

629
static/db_admin.html Normal file
View File

@ -0,0 +1,629 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据库维护页面 - LawRisk</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 30px;
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 3px solid #667eea;
}
.header h1 {
color: #333;
font-size: 28px;
margin-bottom: 10px;
}
.header p {
color: #666;
font-size: 14px;
}
.step-indicator {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 30px;
gap: 10px;
}
.step {
display: flex;
align-items: center;
gap: 10px;
}
.step-number {
width: 36px;
height: 36px;
border-radius: 50%;
background: #e0e7ff;
color: #667eea;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.step.active .step-number {
background: #667eea;
color: white;
}
.step-label {
font-size: 14px;
color: #666;
font-weight: 500;
}
.step.active .step-label {
color: #667eea;
font-weight: bold;
}
.arrow {
color: #ccc;
font-size: 24px;
}
.content-area {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
min-height: 600px;
}
.panel {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e0e0e0;
}
.panel h2 {
color: #333;
font-size: 18px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.selection-area {
background: white;
border-radius: 8px;
padding: 20px;
min-height: 500px;
}
.item-list {
list-style: none;
margin-top: 10px;
}
.item-list li {
padding: 12px 16px;
margin-bottom: 8px;
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
display: flex;
justify-content: space-between;
align-items: center;
}
.item-list li:hover {
background: #e0e7ff;
border-color: #667eea;
transform: translateX(5px);
}
.item-list li.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.item-list li.active:hover {
background: #5568d3;
}
.item-name {
font-size: 15px;
font-weight: 500;
}
.item-count {
font-size: 12px;
background: rgba(102, 126, 234, 0.1);
padding: 4px 10px;
border-radius: 12px;
color: #667eea;
}
.item-list li.active .item-count {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.details-area {
background: white;
border-radius: 8px;
padding: 20px;
min-height: 500px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 15px;
opacity: 0.3;
}
.empty-state p {
font-size: 16px;
}
.details-content {
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-section {
margin-bottom: 25px;
}
.detail-section h3 {
color: #667eea;
font-size: 16px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.detail-section h3::before {
content: '';
width: 4px;
height: 16px;
background: #667eea;
border-radius: 2px;
}
.detail-content {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
border-left: 3px solid #667eea;
line-height: 1.8;
color: #444;
}
.risk-item {
background: white;
padding: 15px;
margin-bottom: 12px;
border-radius: 6px;
border: 1px solid #e0e0e0;
}
.risk-item h4 {
color: #d32f2f;
font-size: 15px;
margin-bottom: 10px;
}
.risk-field {
margin-bottom: 10px;
line-height: 1.6;
}
.risk-field strong {
color: #333;
display: inline-block;
min-width: 80px;
}
.risk-field p {
color: #555;
display: inline;
}
.scope-item {
background: white;
padding: 10px 15px;
margin-bottom: 8px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.permit-status {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
margin-right: 8px;
}
.status-active {
background: #e8f5e9;
color: #2e7d32;
}
.status-inactive {
background: #ffebee;
color: #c62828;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 6px;
margin: 15px 0;
border-left: 4px solid #c62828;
}
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🗃️ 数据库维护系统</h1>
<p>LawRisk 法律风险提示系统 - 数据库维护与查询工具</p>
</div>
<div class="step-indicator">
<div class="step active" id="step1">
<div class="step-number">1</div>
<div class="step-label">选择地区</div>
</div>
<div class="arrow"></div>
<div class="step" id="step2">
<div class="step-number">2</div>
<div class="step-label">选择主题</div>
</div>
<div class="arrow"></div>
<div class="step" id="step3">
<div class="step-number">3</div>
<div class="step-label">选择许可</div>
</div>
<div class="arrow"></div>
<div class="step" id="step4">
<div class="step-number">4</div>
<div class="step-label">查看详情</div>
</div>
</div>
<div class="content-area">
<div class="panel">
<h2>选择区域</h2>
<div class="selection-area">
<div id="regionList" class="item-list"></div>
</div>
</div>
<div class="panel">
<h2>主题/许可详情</h2>
<div class="details-area">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p>请先选择地区以查看可用主题</p>
</div>
</div>
</div>
</div>
</div>
<script>
let currentRegion = null;
let currentTheme = null;
let currentPermit = null;
// 加载地区列表
async function loadRegions() {
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/regions');
const data = await response.json();
if (data.success) {
const regionList = document.getElementById('regionList');
regionList.innerHTML = '';
if (data.data.regions.length === 0) {
regionList.innerHTML = '<div class="error">未找到地区数据</div>';
return;
}
data.data.regions.forEach(region => {
const li = document.createElement('li');
li.innerHTML = `
<span class="item-name">${region.name}</span>
<span class="item-count">加载中...</span>
`;
li.onclick = () => selectRegion(region.id, region.name, li);
regionList.appendChild(li);
});
} else {
document.getElementById('regionList').innerHTML = `<div class="error">加载地区失败:${data.message}</div>`;
}
} catch (error) {
document.getElementById('regionList').innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 选择地区
async function selectRegion(regionId, regionName, element) {
// 更新UI
document.querySelectorAll('#regionList li').forEach(li => li.classList.remove('active'));
element.classList.add('active');
// 更新步骤指示器
updateStepIndicator(2);
currentRegion = { id: regionId, name: regionName };
currentTheme = null;
currentPermit = null;
// 清空并加载主题
const detailsArea = document.querySelector('.details-area');
detailsArea.innerHTML = '<div class="loading"></div>加载主题列表...';
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/themes?region=${regionId}`);
const data = await response.json();
if (data.success) {
const themes = data.data.themes;
if (themes.length === 0) {
detailsArea.innerHTML = `<div class="error">地区 "${regionName}" 下没有可用的主题</div>`;
return;
}
// 创建主题列表
let html = '<div class="item-list">';
themes.forEach(theme => {
html += `
<li onclick="selectTheme('${theme.id}', '${theme.name}', '${theme.option_id}', this)">
<span class="item-name">${theme.name}</span>
<span class="item-count">点击查看许可</span>
</li>
`;
});
html += '</div>';
detailsArea.innerHTML = html;
} else {
detailsArea.innerHTML = `<div class="error">加载主题失败:${data.message}</div>`;
}
} catch (error) {
detailsArea.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 选择主题
async function selectTheme(themeId, themeName, optionId, element) {
// 更新UI
document.querySelectorAll('.details-area .item-list li').forEach(li => li.classList.remove('active'));
element.classList.add('active');
// 更新步骤指示器
updateStepIndicator(3);
currentTheme = { id: themeId, name: themeName };
currentPermit = null;
// 加载许可列表
const detailsArea = document.querySelector('.details-area');
detailsArea.innerHTML = '<div class="loading"></div>加载许可列表...';
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permits?region=${currentRegion.id}&theme=${themeId}`);
const data = await response.json();
if (data.success) {
const permits = data.data.permits;
if (permits.length === 0) {
detailsArea.innerHTML = `<div class="error">主题 "${themeName}" 下没有可用的许可</div>`;
return;
}
// 创建许可列表
let html = '<div class="item-list">';
permits.forEach(permit => {
const riskCount = permit.risks ? permit.risks.length : 0;
html += `
<li onclick="selectPermit('${permit.id}', '${permit.name.replace(/'/g, "\\'")}', '${themeId}', this)">
<span class="item-name">${permit.name}</span>
<span class="item-count">${riskCount} 个风险</span>
</li>
`;
});
html += '</div>';
detailsArea.innerHTML = html;
} else {
detailsArea.innerHTML = `<div class="error">加载许可失败:${data.message}</div>`;
}
} catch (error) {
detailsArea.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 选择许可
async function selectPermit(permitId, permitName, themeId, element) {
// 更新UI
document.querySelectorAll('.details-area .item-list li').forEach(li => li.classList.remove('active'));
element.classList.add('active');
// 更新步骤指示器
updateStepIndicator(4);
currentPermit = { id: permitId, name: permitName };
// 加载许可详情
const detailsArea = document.querySelector('.details-area');
detailsArea.innerHTML = '<div class="loading"></div>加载许可详情...';
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-details?region=${currentRegion.id}&theme=${themeId}&permit=${permitId}`);
const data = await response.json();
if (data.success) {
renderPermitDetails(data.data.permit);
} else {
detailsArea.innerHTML = `<div class="error">加载详情失败:${data.message}</div>`;
}
} catch (error) {
detailsArea.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 渲染许可详情
function renderPermitDetails(permit) {
const detailsArea = document.querySelector('.details-area');
let html = '<div class="details-content">';
// 许可基本信息
html += `
<div class="detail-section">
<h3>许可信息</h3>
<div class="detail-content">
<p><strong>许可名称:</strong>${permit.name}</p>
${permit.permit_status ? `<p style="margin-top: 10px;"><strong>许可状态:</strong><span class="permit-status ${permit.permit_status === 'active' ? 'status-active' : 'status-inactive'}">${permit.permit_status}</span></p>` : ''}
${permit.subitem_summary ? `<p style="margin-top: 10px;"><strong>子项说明:</strong>${permit.subitem_summary}</p>` : ''}
${permit.responsible_contact ? `<p style="margin-top: 10px;"><strong>负责部门:</strong>${permit.responsible_contact}</p>` : ''}
${permit.jurisdiction_scope ? `<p style="margin-top: 10px;"><strong>管辖范围:</strong>${permit.jurisdiction_scope}</p>` : ''}
</div>
</div>
`;
// 经营范围
if (permit.business_scopes && permit.business_scopes.length > 0) {
html += '<div class="detail-section"><h3>经营范围</h3><div class="detail-content">';
permit.business_scopes.forEach(scope => {
html += `<div class="scope-item">${scope.description}</div>`;
});
html += '</div></div>';
}
// 法律风险
if (permit.risks && permit.risks.length > 0) {
html += '<div class="detail-section"><h3>法律风险</h3><div class="detail-content">';
permit.risks.forEach(risk => {
html += `
<div class="risk-item">
<h4>风险 ${risk.id}</h4>
${risk.risk_content ? `<div class="risk-field"><strong>风险内容:</strong><p>${risk.risk_content}</p></div>` : ''}
${risk.legal_basis ? `<div class="risk-field"><strong>法律依据:</strong><p>${risk.legal_basis}</p></div>` : ''}
${risk.document_no ? `<div class="risk-field"><strong>文件编号:</strong><p>${risk.document_no}</p></div>` : ''}
${risk.summary ? `<div class="risk-field"><strong>摘要:</strong><div style="margin-top: 5px;">${risk.summary}</div></div>` : ''}
</div>
`;
});
html += '</div></div>';
} else {
html += `
<div class="detail-section">
<h3>法律风险</h3>
<div class="detail-content">
<p style="color: #999;">暂无法律风险信息</p>
</div>
</div>
`;
}
html += '</div>';
detailsArea.innerHTML = html;
}
// 更新步骤指示器
function updateStepIndicator(step) {
for (let i = 1; i <= 4; i++) {
const stepElement = document.getElementById(`step${i}`);
if (i <= step) {
stepElement.classList.add('active');
} else {
stepElement.classList.remove('active');
}
}
}
// 页面加载时初始化
window.addEventListener('DOMContentLoaded', () => {
loadRegions();
});
</script>
</body>
</html>