feat: implement specialty content module with expanded Page entity fields, database migrations, and data import utilities
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import oracledb
|
||||
|
||||
dsn = "localhost:1521/sisvietnam"
|
||||
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get primaryMenu ID
|
||||
cursor.execute("SELECT id FROM SIS_MENU WHERE name = 'primaryMenu'")
|
||||
menu = cursor.fetchone()
|
||||
if not menu:
|
||||
print("primaryMenu not found")
|
||||
exit(1)
|
||||
menu_id = menu[0]
|
||||
|
||||
# Get CHUYÊN KHOA ID
|
||||
cursor.execute("SELECT id FROM SIS_MENU_ITEM WHERE menu_id = :1 AND UPPER(title) = 'CHUYÊN KHOA'", [menu_id])
|
||||
parent_item = cursor.fetchone()
|
||||
if not parent_item:
|
||||
print("CHUYÊN KHOA menu item not found")
|
||||
exit(1)
|
||||
parent_id = parent_item[0]
|
||||
|
||||
# Get existing children to calculate item_order
|
||||
cursor.execute("SELECT MAX(item_order) FROM SIS_MENU_ITEM WHERE parent_id = :1", [parent_id])
|
||||
max_order = cursor.fetchone()[0]
|
||||
if max_order is None:
|
||||
max_order = 0
|
||||
|
||||
# Get all specialties
|
||||
cursor.execute("SELECT title, slug FROM SIS_PAGE WHERE layout = 'SPECIALTY_DETAIL' ORDER BY title")
|
||||
specialties = cursor.fetchall()
|
||||
|
||||
print(f"Found {len(specialties)} specialties to add.")
|
||||
|
||||
# Add to menu
|
||||
count = 0
|
||||
for spec in specialties:
|
||||
title = spec[0]
|
||||
slug = spec[1]
|
||||
url = f"/chuyen-khoa/{slug}"
|
||||
|
||||
# Check if already exists
|
||||
cursor.execute("SELECT id FROM SIS_MENU_ITEM WHERE parent_id = :1 AND url = :2", [parent_id, url])
|
||||
if cursor.fetchone():
|
||||
print(f"Skipping {title}, already exists in menu.")
|
||||
continue
|
||||
|
||||
max_order += 1
|
||||
|
||||
# Get sequence for ID
|
||||
cursor.execute("SELECT sequence_generator.nextval FROM dual")
|
||||
seq_val = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO SIS_MENU_ITEM (id, menu_id, parent_id, title, url, item_order, css_class, icon_class)
|
||||
VALUES (:1, :2, :3, :4, :5, :6, :7, :8)
|
||||
""", [seq_val, menu_id, parent_id, title, url, max_order, '', ''])
|
||||
|
||||
count += 1
|
||||
print(f"Added {title} ({url})")
|
||||
|
||||
conn.commit()
|
||||
print(f"Successfully added {count} items to menu.")
|
||||
@@ -0,0 +1,35 @@
|
||||
import oracledb
|
||||
|
||||
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn="localhost:1521/sisvietnam")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# === STEP 1: Clean up duplicates ===
|
||||
duplicates_to_delete = [
|
||||
"tim-mach-2",
|
||||
"phau-thuat-gay-me-hoi-suc-2",
|
||||
"khoa-dinh-dung-tiet-che",
|
||||
"khoa-than-kinh-dot-quy-2",
|
||||
]
|
||||
|
||||
# Also delete from menu items first
|
||||
for slug in duplicates_to_delete:
|
||||
cursor.execute("DELETE FROM sis_menu_item WHERE url = :1", [f"/chuyen-khoa/{slug}"])
|
||||
cursor.execute("DELETE FROM sis_page WHERE slug = :1", [slug])
|
||||
print(f"Deleted duplicate: {slug}")
|
||||
|
||||
# Fix title for khoa-than-kinh-dot-quy (was "Khoa Thần kinh - Đột quỵ 2")
|
||||
cursor.execute("UPDATE sis_page SET title = N'Khoa Thần kinh - Đột quỵ' WHERE slug = 'khoa-than-kinh-dot-quy'")
|
||||
|
||||
# Fix title for tim-mach
|
||||
cursor.execute("UPDATE sis_page SET title = N'Khoa Nội Tổng Hợp' WHERE slug = 'tim-mach'")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# === STEP 2: Verify remaining specialties ===
|
||||
cursor.execute("SELECT slug, title FROM sis_page WHERE layout='SPECIALTY_DETAIL' ORDER BY title")
|
||||
rows = cursor.fetchall()
|
||||
print(f"\n=== Remaining specialties: {len(rows)} ===")
|
||||
for row in rows:
|
||||
print(f" {row[0]} | {row[1]}")
|
||||
|
||||
conn.close()
|
||||
@@ -0,0 +1,44 @@
|
||||
import oracledb
|
||||
import json
|
||||
|
||||
def convert_all():
|
||||
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn="localhost:1521/sisvietnam")
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT slug, content FROM sis_page WHERE page_type = 'CUSTOM'")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
count = 0
|
||||
for row in rows:
|
||||
slug = row[0]
|
||||
content = row[1].read() if hasattr(row[1], 'read') else row[1]
|
||||
|
||||
if not content:
|
||||
continue
|
||||
|
||||
try:
|
||||
json.loads(content)
|
||||
# Already JSON
|
||||
continue
|
||||
except ValueError:
|
||||
# Not JSON, convert to Editor.js Raw block
|
||||
editor_json = {
|
||||
"time": 1620000000,
|
||||
"blocks": [
|
||||
{
|
||||
"type": "raw",
|
||||
"data": {
|
||||
"html": content
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "2.22.2"
|
||||
}
|
||||
new_content = json.dumps(editor_json)
|
||||
cursor.execute("UPDATE sis_page SET content = :1 WHERE slug = :2", [new_content, slug])
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
print(f"Converted {count} pages to Editor.js JSON.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
convert_all()
|
||||
@@ -0,0 +1,144 @@
|
||||
import sys
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
def slugify(value):
|
||||
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('utf-8')
|
||||
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
|
||||
return re.sub(r'[-\s]+', '-', value)
|
||||
|
||||
# The slugs used in our DB
|
||||
db_slugs = [
|
||||
"chuyen-khoa-tai-mui-hong",
|
||||
"khoa-chan-doan-hinh-anh",
|
||||
"khoa-cap-cuu",
|
||||
"khoa-dinh-duong-tiet-che",
|
||||
"khoa-duoc",
|
||||
"khoa-kham-benh",
|
||||
"don-vi-kiem-soat-nhiem-khuan",
|
||||
"khoa-ngoai-tong-hop",
|
||||
"tim-mach", # This is Khoa Nội Tổng Hợp in our DB
|
||||
"phau-thuat-gay-me-hoi-suc",
|
||||
"khoa-than-kinh-dot-quy",
|
||||
"khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang",
|
||||
"khoa-xet-nghiem",
|
||||
"phong-quan-ly-van-hanh",
|
||||
"don-vi-can-thiep-mach-dsa",
|
||||
"don-vi-cap-cuu-ngoai-vien",
|
||||
"don-vi-kham-suc-khoe-ngoai-vien"
|
||||
]
|
||||
|
||||
# Manual mapping mapping bvdaihoc.com.vn titles to our slugs (if slugify is not perfect)
|
||||
manual_mapping = {
|
||||
"noi tong hop": "tim-mach",
|
||||
"phau thuat gay me hoi suc phong mo": "phau-thuat-gay-me-hoi-suc",
|
||||
"dsa": "don-vi-can-thiep-mach-dsa",
|
||||
"tai mui hong": "chuyen-khoa-tai-mui-hong",
|
||||
"kiem soat nhiem khuan": "don-vi-kiem-soat-nhiem-khuan",
|
||||
"kham suc khoe ngoai vien": "don-vi-kham-suc-khoe-ngoai-vien",
|
||||
"cap cuu ngoai vien": "don-vi-cap-cuu-ngoai-vien"
|
||||
}
|
||||
|
||||
html_path = '/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html'
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
soup = BeautifulSoup(f, 'html.parser')
|
||||
|
||||
# Find SVGs by looking for "Chuyên khoa" or "Khoa" links
|
||||
icons = {}
|
||||
|
||||
# We'll just look for SVGs in specific items or a tags with title text
|
||||
a_tags = soup.find_all('a')
|
||||
for a in a_tags:
|
||||
svg = a.find('svg')
|
||||
if svg:
|
||||
text = a.text.strip()
|
||||
if text:
|
||||
# Try to match it to our slugs
|
||||
s_text = slugify(text)
|
||||
|
||||
# Simple substring matching
|
||||
matched_slug = None
|
||||
for key, val in manual_mapping.items():
|
||||
if slugify(key) in s_text:
|
||||
matched_slug = val
|
||||
break
|
||||
|
||||
if not matched_slug:
|
||||
for s in db_slugs:
|
||||
if s_text in s or s in s_text or s_text.replace("khoa-", "") in s:
|
||||
matched_slug = s
|
||||
break
|
||||
|
||||
if matched_slug:
|
||||
# Clean up the SVG
|
||||
svg['class'] = "w-8 h-8 text-primary-600 transition-colors duration-300"
|
||||
# Remove some inline styles if any
|
||||
for attr in ['style', 'width', 'height']:
|
||||
if attr in svg.attrs:
|
||||
del svg.attrs[attr]
|
||||
|
||||
icons[matched_slug] = str(svg)
|
||||
|
||||
# If we didn't find many icons, let's look for div with specific classes
|
||||
if len(icons) < 5:
|
||||
print("Found few icons, looking for specific classes...")
|
||||
items = soup.select('.elementor-widget-icon-box .elementor-icon-box-icon svg, .elementor-icon svg')
|
||||
for svg in items:
|
||||
# traverse up to find title
|
||||
parent = svg.parent
|
||||
title = ""
|
||||
while parent and parent.name not in ['body']:
|
||||
header = parent.find(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
||||
if header and header.text.strip():
|
||||
title = header.text.strip()
|
||||
break
|
||||
parent = parent.parent
|
||||
|
||||
if title:
|
||||
s_text = slugify(title)
|
||||
matched_slug = None
|
||||
for key, val in manual_mapping.items():
|
||||
if slugify(key) in s_text:
|
||||
matched_slug = val
|
||||
break
|
||||
|
||||
if not matched_slug:
|
||||
for s in db_slugs:
|
||||
if s_text in s or s in s_text:
|
||||
matched_slug = s
|
||||
break
|
||||
|
||||
if matched_slug:
|
||||
# Clean up the SVG
|
||||
svg['class'] = "w-8 h-8 text-primary-600 transition-colors duration-300"
|
||||
for attr in ['style', 'width', 'height']:
|
||||
if attr in svg.attrs:
|
||||
del svg.attrs[attr]
|
||||
icons[matched_slug] = str(svg)
|
||||
|
||||
|
||||
print(f"Mapped {len(icons)} icons to specialties")
|
||||
|
||||
# Generate the thymeleaf switch block
|
||||
switch_code = '<th:block th:switch="${spec.slug}">\n'
|
||||
for slug, svg_html in icons.items():
|
||||
switch_code += f' <!-- {slug} -->\n'
|
||||
switch_code += f' <th:block th:case="\'{slug}\'">\n'
|
||||
switch_code += f' {svg_html}\n'
|
||||
switch_code += f' </th:block>\n'
|
||||
|
||||
# Default case
|
||||
switch_code += ' <!-- Default -->\n'
|
||||
switch_code += ' <th:block th:case="*">\n'
|
||||
switch_code += ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-8 h-8 text-primary-600 transition-colors duration-300">\n'
|
||||
switch_code += ' <path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6"/>\n'
|
||||
switch_code += ' </svg>\n'
|
||||
switch_code += ' </th:block>\n'
|
||||
switch_code += ' </th:block>'
|
||||
|
||||
# Write the new block to a file so we can see it
|
||||
with open('/tmp/icons_block.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(switch_code)
|
||||
|
||||
print("Wrote switch block to /tmp/icons_block.txt")
|
||||
@@ -0,0 +1,119 @@
|
||||
import sys
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
def slugify(value):
|
||||
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('utf-8')
|
||||
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
|
||||
return re.sub(r'[-\s]+', '-', value)
|
||||
|
||||
# The slugs used in our DB
|
||||
db_slugs = [
|
||||
"chuyen-khoa-tai-mui-hong",
|
||||
"khoa-chan-doan-hinh-anh",
|
||||
"khoa-cap-cuu",
|
||||
"khoa-dinh-duong-tiet-che",
|
||||
"khoa-duoc",
|
||||
"khoa-kham-benh",
|
||||
"don-vi-kiem-soat-nhiem-khuan",
|
||||
"khoa-ngoai-tong-hop",
|
||||
"tim-mach", # This is Khoa Nội Tổng Hợp in our DB
|
||||
"phau-thuat-gay-me-hoi-suc",
|
||||
"khoa-than-kinh-dot-quy",
|
||||
"khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang",
|
||||
"khoa-xet-nghiem",
|
||||
"phong-quan-ly-van-hanh",
|
||||
"don-vi-can-thiep-mach-dsa",
|
||||
"don-vi-cap-cuu-ngoai-vien",
|
||||
"don-vi-kham-suc-khoe-ngoai-vien"
|
||||
]
|
||||
|
||||
manual_mapping = {
|
||||
"noi tong hop": "tim-mach",
|
||||
"phau thuat gay me hoi suc phong mo": "phau-thuat-gay-me-hoi-suc",
|
||||
"dsa": "don-vi-can-thiep-mach-dsa",
|
||||
"tai mui hong": "chuyen-khoa-tai-mui-hong",
|
||||
"kiem soat nhiem khuan": "don-vi-kiem-soat-nhiem-khuan",
|
||||
"kham suc khoe ngoai vien": "don-vi-kham-suc-khoe-ngoai-vien",
|
||||
"cap cuu ngoai vien": "don-vi-cap-cuu-ngoai-vien"
|
||||
}
|
||||
|
||||
html_path = '/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html'
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
soup = BeautifulSoup(f, 'html.parser')
|
||||
|
||||
icons = {}
|
||||
links = soup.find_all('a', href=True)
|
||||
|
||||
for a in links:
|
||||
href = a.get('href')
|
||||
if '/chuyen-khoa/' in href:
|
||||
img = a.find('img')
|
||||
if img:
|
||||
src = img.get('src')
|
||||
if not src:
|
||||
# Next image srcset or similar fallback
|
||||
src = img.get('data-src') or img.get('srcset')
|
||||
if src and ' ' in src:
|
||||
src = src.split(' ')[0]
|
||||
|
||||
title = href.split('/')[-1] # use the slug from href
|
||||
|
||||
# Find the actual name
|
||||
name_span = a.find('span')
|
||||
if name_span:
|
||||
title = name_span.text.strip()
|
||||
else:
|
||||
title_text = a.text.strip()
|
||||
if title_text:
|
||||
title = title_text
|
||||
|
||||
if src:
|
||||
# Decode src if it's encoded or make it absolute
|
||||
if src.startswith('/_next/image?url='):
|
||||
import urllib.parse
|
||||
src = urllib.parse.unquote(src.replace('/_next/image?url=', '').split('&')[0])
|
||||
|
||||
# Match it
|
||||
s_text = slugify(title)
|
||||
matched_slug = None
|
||||
for key, val in manual_mapping.items():
|
||||
if slugify(key) in s_text:
|
||||
matched_slug = val
|
||||
break
|
||||
|
||||
if not matched_slug:
|
||||
for s in db_slugs:
|
||||
if s_text in s or s in s_text or s_text.replace("khoa-", "") in s:
|
||||
matched_slug = s
|
||||
break
|
||||
|
||||
if matched_slug:
|
||||
icons[matched_slug] = src
|
||||
|
||||
print(f"Mapped {len(icons)} image icons to specialties")
|
||||
|
||||
# Generate the thymeleaf switch block for images
|
||||
switch_code = '<th:block th:switch="${spec.slug}">\n'
|
||||
for slug, src in icons.items():
|
||||
switch_code += f' <!-- {slug} -->\n'
|
||||
switch_code += f' <th:block th:case="\'{slug}\'">\n'
|
||||
switch_code += f' <img src="{src}" alt="Icon" class="w-12 h-12 object-contain" />\n'
|
||||
switch_code += f' </th:block>\n'
|
||||
|
||||
# Default case
|
||||
switch_code += ' <!-- Default -->\n'
|
||||
switch_code += ' <th:block th:case="*">\n'
|
||||
switch_code += ' <div class="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600">\n'
|
||||
switch_code += ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">\n'
|
||||
switch_code += ' <path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6"/>\n'
|
||||
switch_code += ' </svg>\n'
|
||||
switch_code += ' </div>\n'
|
||||
switch_code += ' </th:block>\n'
|
||||
switch_code += ' </th:block>'
|
||||
|
||||
with open('/tmp/icons_block_img.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(switch_code)
|
||||
|
||||
print("Wrote switch block to /tmp/icons_block_img.txt")
|
||||
@@ -0,0 +1,115 @@
|
||||
import urllib.parse
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
html_path = '/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html'
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
|
||||
def slugify(value):
|
||||
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('utf-8')
|
||||
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
|
||||
return re.sub(r'[-\s]+', '-', value)
|
||||
|
||||
db_slugs = [
|
||||
"chuyen-khoa-tai-mui-hong", "khoa-chan-doan-hinh-anh", "khoa-cap-cuu",
|
||||
"khoa-dinh-duong-tiet-che", "khoa-duoc", "khoa-kham-benh",
|
||||
"don-vi-kiem-soat-nhiem-khuan", "khoa-ngoai-tong-hop", "tim-mach",
|
||||
"phau-thuat-gay-me-hoi-suc", "khoa-than-kinh-dot-quy",
|
||||
"khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang", "khoa-xet-nghiem",
|
||||
"phong-quan-ly-van-hanh", "don-vi-can-thiep-mach-dsa",
|
||||
"don-vi-cap-cuu-ngoai-vien", "don-vi-kham-suc-khoe-ngoai-vien"
|
||||
]
|
||||
|
||||
manual_mapping = {
|
||||
"noi tong hop": "tim-mach",
|
||||
"phau thuat gay me hoi suc phong mo": "phau-thuat-gay-me-hoi-suc",
|
||||
"dsa": "don-vi-can-thiep-mach-dsa",
|
||||
"tai mui hong": "chuyen-khoa-tai-mui-hong",
|
||||
"kiem soat nhiem khuan": "don-vi-kiem-soat-nhiem-khuan",
|
||||
"kham suc khoe ngoai vien": "don-vi-kham-suc-khoe-ngoai-vien",
|
||||
"cap cuu ngoai vien": "don-vi-cap-cuu-ngoai-vien",
|
||||
"chan doan hinh anh": "khoa-chan-doan-hinh-anh",
|
||||
"vat ly tri lieu phuc hoi chuc nang": "khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang",
|
||||
"phuc hoi chuc nang": "khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang",
|
||||
"kham benh": "khoa-kham-benh",
|
||||
"cap cuu": "khoa-cap-cuu",
|
||||
"than kinh": "khoa-than-kinh-dot-quy",
|
||||
"ngoai tong hop": "khoa-ngoai-tong-hop",
|
||||
"xet nghiem": "khoa-xet-nghiem",
|
||||
"dinh duong tiet che": "khoa-dinh-duong-tiet-che"
|
||||
}
|
||||
|
||||
chunks = text.split('href="https://bvdaihoc.com.vn/chuyen-khoa/')
|
||||
|
||||
icons = {}
|
||||
for chunk in chunks[1:]:
|
||||
slug_in_href = chunk.split('"')[0]
|
||||
if '?' in slug_in_href: continue
|
||||
|
||||
# Try to find absolute img src in srcset
|
||||
src = None
|
||||
srcset_m = re.search(r'srcset=\"([^\"]+)\"', chunk)
|
||||
if srcset_m:
|
||||
# get the first url from srcset
|
||||
srcsets = srcset_m.group(1).split(', ')
|
||||
first = srcsets[0].split(' ')[0]
|
||||
if first.startswith('/_next/image?url='):
|
||||
src = urllib.parse.unquote(first.replace('/_next/image?url=', '').split('&')[0])
|
||||
|
||||
if not src:
|
||||
# look for absolute console.bvdaihoc url anywhere in chunk
|
||||
abs_m = re.search(r'https%3A%2F%2Fconsole.bvdaihoc.com.vn%2Fuploads%2Fchuyen-khoa%2F[^&\s\"\'\\]+', chunk)
|
||||
if abs_m:
|
||||
src = urllib.parse.unquote(abs_m.group(0))
|
||||
|
||||
if src:
|
||||
title = slug_in_href
|
||||
text_match = re.findall(r'>([^<]+)<', chunk[:1000])
|
||||
texts = [t.strip() for t in text_match if len(t.strip()) > 3 and not t.strip().startswith('&')]
|
||||
if texts:
|
||||
title = texts[0]
|
||||
|
||||
s_text = slugify(title)
|
||||
matched = None
|
||||
for key, val in manual_mapping.items():
|
||||
if slugify(key) in s_text:
|
||||
matched = val
|
||||
break
|
||||
|
||||
if not matched:
|
||||
for s in db_slugs:
|
||||
if s_text in s or s in s_text or s_text.replace("khoa-", "") in s:
|
||||
matched = s
|
||||
break
|
||||
|
||||
if matched:
|
||||
icons[matched] = src
|
||||
else:
|
||||
s_slug = slugify(slug_in_href)
|
||||
for s in db_slugs:
|
||||
if s_slug in s or s in s_slug:
|
||||
icons[s] = src
|
||||
break
|
||||
|
||||
switch_code = ' <th:block th:switch="${spec.slug}">\n'
|
||||
for slug, src in sorted(icons.items()):
|
||||
switch_code += f' <!-- {slug} -->\n'
|
||||
switch_code += f' <th:block th:case="\'{slug}\'">\n'
|
||||
switch_code += f' <img src="{src}" alt="Icon" class="w-12 h-12 object-contain mx-auto" />\n'
|
||||
switch_code += f' </th:block>\n'
|
||||
|
||||
switch_code += ' <!-- Default -->\n'
|
||||
switch_code += ' <th:block th:case="*">\n'
|
||||
switch_code += ' <div class="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 mx-auto">\n'
|
||||
switch_code += ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">\n'
|
||||
switch_code += ' <path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6"/>\n'
|
||||
switch_code += ' </svg>\n'
|
||||
switch_code += ' </div>\n'
|
||||
switch_code += ' </th:block>\n'
|
||||
switch_code += ' </th:block>'
|
||||
|
||||
with open('/tmp/icons_block_img.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(switch_code)
|
||||
|
||||
print("Wrote block to /tmp/icons_block_img.txt")
|
||||
@@ -0,0 +1,110 @@
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
import oracledb
|
||||
import json
|
||||
import re
|
||||
|
||||
def extract_siteorigin_html(content_text):
|
||||
# Find all occurrences of siteorigin-panels block
|
||||
# It might look like <!-- wp:siteorigin-panels/layout-block {"panelsData":{...}} /-->
|
||||
html_parts = []
|
||||
|
||||
# regex to match <!-- wp:siteorigin-panels/layout-block {json} /-->
|
||||
matches = re.finditer(r'<!--\s*wp:siteorigin-panels/layout-block\s+(.*?)\s*/?-->', content_text, re.DOTALL)
|
||||
|
||||
has_siteorigin = False
|
||||
for match in matches:
|
||||
has_siteorigin = True
|
||||
json_str = match.group(1).strip()
|
||||
if json_str.endswith('/'):
|
||||
json_str = json_str[:-1].strip()
|
||||
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
if 'panelsData' in data and 'widgets' in data['panelsData']:
|
||||
widgets = data['panelsData']['widgets']
|
||||
for widget in widgets:
|
||||
if 'text' in widget:
|
||||
# Extract the HTML text from the widget
|
||||
text = widget['text']
|
||||
# The text might have unicode escapes like \u003c
|
||||
# json.loads already decoded it!
|
||||
html_parts.append(text)
|
||||
except Exception as e:
|
||||
print(f"Failed to parse JSON: {e}")
|
||||
pass
|
||||
|
||||
if has_siteorigin:
|
||||
return "\n".join(html_parts)
|
||||
return content_text
|
||||
|
||||
def wrap_in_editorjs(html_content):
|
||||
if not html_content.strip():
|
||||
return ""
|
||||
editor_json = {
|
||||
"time": 1620000000,
|
||||
"blocks": [
|
||||
{
|
||||
"type": "raw",
|
||||
"data": {
|
||||
"html": html_content
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "2.22.2"
|
||||
}
|
||||
return json.dumps(editor_json)
|
||||
|
||||
def main(xml_file_path):
|
||||
print(f"Reading {xml_file_path} ...")
|
||||
tree = ET.parse(xml_file_path)
|
||||
root = tree.getroot()
|
||||
|
||||
ns = {
|
||||
'wp': 'http://wordpress.org/export/1.2/',
|
||||
'content': 'http://purl.org/rss/1.0/modules/content/'
|
||||
}
|
||||
|
||||
dsn = "localhost:1521/sisvietnam"
|
||||
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
|
||||
cursor = conn.cursor()
|
||||
|
||||
count = 0
|
||||
|
||||
for item in root.findall('.//item'):
|
||||
post_name_tag = item.find('wp:post_name', ns)
|
||||
if post_name_tag is not None and post_name_tag.text:
|
||||
slug = post_name_tag.text
|
||||
else:
|
||||
continue
|
||||
|
||||
slug = urllib.parse.unquote(slug)
|
||||
if slug == "khoa-than-kinh-dot-quy-2":
|
||||
slug = "khoa-than-kinh-dot-quy"
|
||||
|
||||
content_tag = item.find('content:encoded', ns)
|
||||
content_text = content_tag.text if content_tag is not None and content_tag.text else ""
|
||||
|
||||
# Check if it has siteorigin
|
||||
if 'wp:siteorigin-panels/layout-block' in content_text:
|
||||
print(f"Found SiteOrigin content in: {slug}")
|
||||
extracted_html = extract_siteorigin_html(content_text)
|
||||
|
||||
# Wrap in wrapper
|
||||
extracted_html = f'<div class="wp-content-container prose max-w-none">\n{extracted_html}\n</div>'
|
||||
|
||||
final_content = wrap_in_editorjs(extracted_html)
|
||||
|
||||
cursor.execute("UPDATE sis_page SET content = :1 WHERE slug = :2", [final_content, slug])
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
print(f"\n--- TỔNG KẾT ---")
|
||||
print(f"Successfully processed {count} SiteOrigin pages.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
main(sys.argv[1])
|
||||
else:
|
||||
print("Usage: python3 fix_siteorigin.py <xml_file>")
|
||||
@@ -0,0 +1,168 @@
|
||||
import oracledb
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
def transform_html(raw_html):
|
||||
if not raw_html:
|
||||
return "", None, None, None, None
|
||||
|
||||
soup = BeautifulSoup(raw_html, "html.parser")
|
||||
|
||||
# Extract all images
|
||||
images = []
|
||||
for img in soup.find_all('img'):
|
||||
src = img.get('src')
|
||||
if src:
|
||||
images.append(src)
|
||||
img.decompose() # Remove from flow
|
||||
|
||||
hero_image = images[0] if len(images) > 0 else "https://sisvietnam.vn/wp-content/uploads/2021/07/DSC07517-1024x576.jpg"
|
||||
other_images = images[1:]
|
||||
|
||||
# Extract Contact Info
|
||||
text = soup.get_text()
|
||||
email_match = re.search(r"(?i)Email\s*:\s*([\w\.-]+@[\w\.-]+)", text)
|
||||
phone_match = re.search(r"(?i)(?:SĐT|Điện thoại|ĐT|ĐIỆN THOẠI)\s*:\s*([\d\.\s-]+)", text)
|
||||
address_match = re.search(r"(?i)Địa chỉ\s*:\s*([^\n]+)", text)
|
||||
|
||||
contact_email = email_match.group(1).strip() if email_match else None
|
||||
contact_phone = phone_match.group(1).strip() if phone_match else None
|
||||
contact_address = address_match.group(1).strip() if address_match else None
|
||||
|
||||
# Parse sections based on wp:list or headings
|
||||
sections_data = []
|
||||
|
||||
top_uls = soup.find_all('ul', recursive=False)
|
||||
if not top_uls and soup.find('div', class_='wp-content-container'):
|
||||
top_uls = soup.find('div', class_='wp-content-container').find_all('ul', recursive=False)
|
||||
|
||||
for ul in top_uls:
|
||||
heading = "Nội dung"
|
||||
strong_tag = ul.find('strong')
|
||||
if strong_tag:
|
||||
heading = strong_tag.text.strip()
|
||||
strong_tag.decompose()
|
||||
|
||||
content_html = ""
|
||||
for li in ul.find_all('li', recursive=False):
|
||||
inner_uls = li.find_all('ul')
|
||||
if inner_uls:
|
||||
for inner in inner_uls:
|
||||
content_html += str(inner)
|
||||
else:
|
||||
content_html += str(li) # fallback to just raw text if no inner UL
|
||||
|
||||
if not content_html:
|
||||
content_html = str(ul)
|
||||
|
||||
sections_data.append({
|
||||
'heading': heading,
|
||||
'content': content_html
|
||||
})
|
||||
|
||||
if not sections_data:
|
||||
sections_data.append({
|
||||
'heading': 'Thông tin chi tiết',
|
||||
'content': str(soup)
|
||||
})
|
||||
|
||||
out = ""
|
||||
bg_colors = ["bg-white", "bg-gray-50", "bg-white", "bg-gray-100"]
|
||||
for i, sec in enumerate(sections_data):
|
||||
bg = bg_colors[i % len(bg_colors)]
|
||||
|
||||
img_html = ""
|
||||
if i < len(other_images):
|
||||
img_src = other_images[i]
|
||||
img_html = f"""
|
||||
<div class="w-full lg:w-1/2">
|
||||
<div class="rounded-2xl overflow-hidden shadow-lg aspect-[4/3] relative">
|
||||
<img src="{img_src}" class="absolute inset-0 w-full h-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
flex_dir = "lg:flex-row-reverse" if i % 2 == 1 else "lg:flex-row"
|
||||
text_width = "lg:w-1/2" if img_html else "lg:w-3/4 lg:mx-auto"
|
||||
|
||||
out += f"""
|
||||
<!-- SECTION {i+1} -->
|
||||
<section class="xl:py-12 md:py-8 py-4 {bg}">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex flex-col gap-6 {flex_dir} lg:items-center">
|
||||
<div class="w-full {text_width}">
|
||||
<h2 class="display-6 text-primary-600 mb-4 md:mb-6 xl:mb-8">{sec['heading']}</h2>
|
||||
<div class="prose max-w-none text-gray-700 body-2" style="list-style-type: disc;">
|
||||
{sec['content']}
|
||||
</div>
|
||||
</div>
|
||||
{img_html}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
return out, hero_image, contact_email, contact_address, contact_phone
|
||||
|
||||
def main():
|
||||
print("Loading original HTML from XML...")
|
||||
file_path = "/home/x79/sisvietnamvn_01/sisvietnamvn_Trang chính thức hiện tại/post_chuyenkhoa.xml"
|
||||
tree = ET.parse(file_path)
|
||||
root = tree.getroot()
|
||||
channel = root.find("channel")
|
||||
ns = {"wp": "http://wordpress.org/export/1.2/", "content": "http://purl.org/rss/1.0/modules/content/"}
|
||||
|
||||
slug_to_raw = {}
|
||||
for item in channel.findall("item"):
|
||||
post_name = item.find("wp:post_name", ns).text
|
||||
content_node = item.find("content:encoded", ns)
|
||||
if content_node is not None and content_node.text:
|
||||
slug_to_raw[post_name] = content_node.text
|
||||
|
||||
print(f"Loaded {len(slug_to_raw)} raw posts from XML")
|
||||
|
||||
dsn = "localhost:1521/sisvietnam"
|
||||
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT slug, title FROM sis_page WHERE page_type = 'CUSTOM'")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
count = 0
|
||||
warnings = []
|
||||
|
||||
for row in rows:
|
||||
slug = row[0]
|
||||
title = row[1]
|
||||
|
||||
# If slug doesn't match exactly, maybe some slight difference? We'll try exact match
|
||||
if slug not in slug_to_raw:
|
||||
print(f"Slug {slug} not found in XML! Skipping.")
|
||||
continue
|
||||
|
||||
raw_html = slug_to_raw[slug]
|
||||
|
||||
# Wrap it in wp-content-container for parsing if needed
|
||||
if 'wp-content-container' not in raw_html:
|
||||
raw_html = f'<div class="wp-content-container">{raw_html}</div>'
|
||||
|
||||
transformed_content, hero_image, email, address, phone = transform_html(raw_html)
|
||||
|
||||
if "Thông tin chi tiết" in transformed_content and "wp-content-container" not in transformed_content:
|
||||
warnings.append(slug)
|
||||
|
||||
# Update DB
|
||||
update_sql = """
|
||||
UPDATE sis_page
|
||||
SET content = :1, hero_image = :2, contact_email = :3, contact_address = :4, contact_phone = :5
|
||||
WHERE slug = :6
|
||||
"""
|
||||
cursor.execute(update_sql, [transformed_content, hero_image, email, address, phone, slug])
|
||||
count += 1
|
||||
print(f"Restored and Updated {slug}")
|
||||
|
||||
conn.commit()
|
||||
print(f"Successfully fixed {count} pages.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
import oracledb
|
||||
from datetime import datetime
|
||||
import sys
|
||||
import json
|
||||
|
||||
def wrap_in_editorjs(html_content):
|
||||
editor_json = {
|
||||
"time": int(datetime.now().timestamp() * 1000),
|
||||
"blocks": [
|
||||
{
|
||||
"type": "raw",
|
||||
"data": {
|
||||
"html": html_content
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "2.22.2"
|
||||
}
|
||||
return json.dumps(editor_json)
|
||||
|
||||
def main(xml_file_path):
|
||||
print(f"Reading {xml_file_path} ...")
|
||||
tree = ET.parse(xml_file_path)
|
||||
root = tree.getroot()
|
||||
|
||||
ns = {
|
||||
'wp': 'http://wordpress.org/export/1.2/',
|
||||
'content': 'http://purl.org/rss/1.0/modules/content/',
|
||||
'dc': 'http://purl.org/dc/elements/1.1/'
|
||||
}
|
||||
|
||||
dsn = "localhost:1521/sisvietnam"
|
||||
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
|
||||
cursor = conn.cursor()
|
||||
|
||||
dt = datetime.now()
|
||||
layout = "SPECIALTY_DETAIL"
|
||||
page_type = "CUSTOM"
|
||||
status = "PUBLISHED"
|
||||
|
||||
count = 0
|
||||
warnings = []
|
||||
|
||||
for channel in root.findall('channel'):
|
||||
for item in channel.findall('item'):
|
||||
title_tag = item.find('title')
|
||||
title = title_tag.text if title_tag is not None else "Untitled"
|
||||
|
||||
post_name_tag = item.find('wp:post_name', ns)
|
||||
if post_name_tag is not None and post_name_tag.text:
|
||||
slug = post_name_tag.text
|
||||
else:
|
||||
link_tag = item.find('link')
|
||||
slug = link_tag.text.strip('/').split('/')[-1] if link_tag is not None else "unknown"
|
||||
|
||||
slug = urllib.parse.unquote(slug)
|
||||
|
||||
if slug == "khoa-than-kinh-dot-quy-2":
|
||||
slug = "khoa-than-kinh-dot-quy"
|
||||
|
||||
# No longer skipping khoa-cap-cuu
|
||||
|
||||
content_tag = item.find('content:encoded', ns)
|
||||
content_text = content_tag.text if content_tag is not None and content_tag.text else ""
|
||||
|
||||
# Check if missing container
|
||||
if '<div class="wp-content-container' not in content_text and content_text.strip():
|
||||
warnings.append(f"CẢNH BÁO: Bài viết '{title}' ({slug}) thiếu thẻ container. Hệ thống đã tự động bổ sung.")
|
||||
content_text = f'<div class="wp-content-container prose max-w-none">\n{content_text}\n</div>'
|
||||
|
||||
# Wrap into Editor.js JSON
|
||||
final_content = wrap_in_editorjs(content_text)
|
||||
|
||||
cursor.execute("SELECT id FROM sis_page WHERE slug = :1", [slug])
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
sql = "UPDATE sis_page SET title = :1, content = :2, layout = :3, page_type = :4 WHERE slug = :5"
|
||||
cursor.execute(sql, [title, final_content, layout, page_type, slug])
|
||||
else:
|
||||
sql = """
|
||||
INSERT INTO sis_page (
|
||||
id, title, slug, content, status, page_type, display_order, layout,
|
||||
created_date, last_modified_date, created_by, last_modified_by
|
||||
) VALUES (
|
||||
sequence_generator.nextval, :title, :slug, :content, :status, :page_type, 0, :layout,
|
||||
:created_date, :last_modified_date, 'system', 'system'
|
||||
)
|
||||
"""
|
||||
cursor.execute(sql, {
|
||||
'title': title,
|
||||
'slug': slug,
|
||||
'content': final_content,
|
||||
'status': status,
|
||||
'page_type': page_type,
|
||||
'layout': layout,
|
||||
'created_date': dt,
|
||||
'last_modified_date': dt
|
||||
})
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
print(f"\n--- TỔNG KẾT ---")
|
||||
print(f"Successfully processed {count} specialty pages.")
|
||||
for w in warnings:
|
||||
print(w)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
main(sys.argv[1])
|
||||
else:
|
||||
print("Usage: python3 import_specialties.py <path_to_xml>")
|
||||
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs');
|
||||
const html = fs.readFileSync('/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html', 'utf8');
|
||||
|
||||
const chunks = html.split('href="https://bvdaihoc.com.vn/chuyen-khoa/');
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const slug = chunks[i].split('"')[0];
|
||||
if (slug.includes('?category=')) continue;
|
||||
|
||||
const imgIdx = chunks[i].indexOf('<img ');
|
||||
if (imgIdx !== -1) {
|
||||
const srcIdx = chunks[i].indexOf('src="', imgIdx);
|
||||
if (srcIdx !== -1) {
|
||||
let src = chunks[i].substring(srcIdx + 5).split('"')[0];
|
||||
if (src.startsWith('/_next/image?url=')) {
|
||||
src = decodeURIComponent(src.replace('/_next/image?url=', '').split('&')[0]);
|
||||
}
|
||||
console.log(slug + ': ' + src);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const fs = require('fs');
|
||||
const html = fs.readFileSync('/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html', 'utf8');
|
||||
|
||||
let imgCount = 0;
|
||||
let idx = 0;
|
||||
while (true) {
|
||||
idx = html.indexOf('<img', idx);
|
||||
if (idx === -1) break;
|
||||
|
||||
const endIdx = html.indexOf('>', idx);
|
||||
const tag = html.substring(idx, endIdx + 1);
|
||||
|
||||
// get parent context 100 chars before
|
||||
const ctxBefore = html.substring(Math.max(0, idx - 100), idx);
|
||||
|
||||
if (ctxBefore.includes('chuyen-khoa')) {
|
||||
console.log('Context:', ctxBefore);
|
||||
console.log('Tag:', tag);
|
||||
console.log('---');
|
||||
imgCount++;
|
||||
}
|
||||
|
||||
idx = endIdx;
|
||||
if (imgCount > 5) break;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const html = fs.readFileSync('/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Khoa Giải phẫu bệnh.html', 'utf8');
|
||||
|
||||
const h2s = html.match(/<h2[^>]*>([\s\S]*?)<\/h2>/gi);
|
||||
console.log('H2s:', h2s ? h2s.map(h => h.replace(/<[^>]+>/g, '').trim()) : 'None');
|
||||
|
||||
const h3s = html.match(/<h3[^>]*>([\s\S]*?)<\/h3>/gi);
|
||||
console.log('H3s:', h3s ? h3s.map(h => h.replace(/<[^>]+>/g, '').trim()) : 'None');
|
||||
@@ -0,0 +1,103 @@
|
||||
import oracledb
|
||||
import sys
|
||||
|
||||
try:
|
||||
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn="localhost:1521/sisvietnam")
|
||||
except Exception as e:
|
||||
print(f"Connection failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get menu by ID directly (we know it's 2001)
|
||||
menu_id = 2001
|
||||
|
||||
# Find CHUYÊN KHOA parent item - search by label or title containing "khoa"
|
||||
cursor.execute("""
|
||||
SELECT id, label, title FROM sis_menu_item
|
||||
WHERE menu_id = :1 AND parent_id IS NULL
|
||||
ORDER BY display_order
|
||||
""", [menu_id])
|
||||
top_items = cursor.fetchall()
|
||||
print("Top-level menu items:")
|
||||
ck_id = None
|
||||
for r in top_items:
|
||||
print(f" id={r[0]} label={r[1]} title={r[2]}")
|
||||
if r[1] and 'KHOA' in r[1].upper():
|
||||
ck_id = r[0]
|
||||
elif r[2] and 'KHOA' in r[2].upper():
|
||||
ck_id = r[0]
|
||||
|
||||
if not ck_id:
|
||||
print("\nCHUYÊN KHOA menu item not found! Creating one...")
|
||||
ck_id_val = cursor.execute("SELECT sequence_generator.nextval FROM dual").fetchone()[0]
|
||||
from datetime import datetime
|
||||
dt = datetime.now()
|
||||
cursor.execute("""
|
||||
INSERT INTO sis_menu_item (id, menu_id, label, url, parent_id, display_order, title, created_date, last_modified_date, created_by, last_modified_by)
|
||||
VALUES (:id, :menu_id, :label, :url, NULL, 2, :title, :cd, :md, 'system', 'system')
|
||||
""", {
|
||||
'id': ck_id_val, 'menu_id': menu_id, 'label': 'CHUYÊN KHOA',
|
||||
'url': '/chuyen-khoa', 'title': 'CHUYÊN KHOA', 'cd': dt, 'md': dt
|
||||
})
|
||||
ck_id = ck_id_val
|
||||
print(f"Created CHUYÊN KHOA menu item with id={ck_id}")
|
||||
else:
|
||||
print(f"\nFound CHUYÊN KHOA menu item with id={ck_id}")
|
||||
|
||||
# Delete existing children under CHUYÊN KHOA (clear and rebuild)
|
||||
cursor.execute("""
|
||||
DELETE FROM sis_menu_item WHERE parent_id IN (
|
||||
SELECT id FROM sis_menu_item WHERE parent_id = :1
|
||||
)
|
||||
""", [ck_id])
|
||||
cursor.execute("DELETE FROM sis_menu_item WHERE parent_id = :1", [ck_id])
|
||||
print("Cleared existing children")
|
||||
|
||||
from datetime import datetime
|
||||
dt = datetime.now()
|
||||
|
||||
def next_id():
|
||||
cursor.execute("SELECT sequence_generator.nextval FROM dual")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
categories = [
|
||||
{"label": "Khoa lâm sàng", "url": "/chuyen-khoa?category=khoa-lam-sang", "order": 1, "db_cat": "KHOA_LAM_SANG"},
|
||||
{"label": "Khoa cận lâm sàng", "url": "/chuyen-khoa?category=khoa-can-lam-sang", "order": 2, "db_cat": "KHOA_CAN_LAM_SANG"},
|
||||
{"label": "Khoa hỗ trợ lâm sàng", "url": "/chuyen-khoa?category=khoa-ho-tro-lam-sang", "order": 3, "db_cat": "KHOA_HO_TRO_LAM_SANG"},
|
||||
]
|
||||
|
||||
for cat in categories:
|
||||
cat_item_id = next_id()
|
||||
cursor.execute("""
|
||||
INSERT INTO sis_menu_item (id, menu_id, label, url, parent_id, display_order, title, created_date, last_modified_date, created_by, last_modified_by)
|
||||
VALUES (:id, :menu_id, :label, :url, :parent_id, :display_order, :title, :cd, :md, 'system', 'system')
|
||||
""", {
|
||||
'id': cat_item_id, 'menu_id': menu_id, 'label': cat['label'],
|
||||
'url': cat['url'], 'parent_id': ck_id, 'display_order': cat['order'],
|
||||
'title': cat['label'], 'cd': dt, 'md': dt
|
||||
})
|
||||
print(f"\nCreated sub-group: {cat['label']} (id={cat_item_id})")
|
||||
|
||||
cursor.execute("""
|
||||
SELECT slug, title FROM sis_page
|
||||
WHERE layout = 'SPECIALTY_DETAIL' AND specialty_category = :1
|
||||
ORDER BY title
|
||||
""", [cat['db_cat']])
|
||||
specs = cursor.fetchall()
|
||||
|
||||
for idx, spec in enumerate(specs):
|
||||
spec_id = next_id()
|
||||
cursor.execute("""
|
||||
INSERT INTO sis_menu_item (id, menu_id, label, url, parent_id, display_order, title, created_date, last_modified_date, created_by, last_modified_by)
|
||||
VALUES (:id, :menu_id, :label, :url, :parent_id, :display_order, :title, :cd, :md, 'system', 'system')
|
||||
""", {
|
||||
'id': spec_id, 'menu_id': menu_id, 'label': spec[1],
|
||||
'url': f"/chuyen-khoa/{spec[0]}", 'parent_id': cat_item_id,
|
||||
'display_order': idx + 1, 'title': spec[1], 'cd': dt, 'md': dt
|
||||
})
|
||||
print(f" Added: {spec[1]}")
|
||||
|
||||
conn.commit()
|
||||
print("\n✅ Menu reorganization complete!")
|
||||
conn.close()
|
||||
@@ -27,7 +27,7 @@ try:
|
||||
|
||||
# Fetch the latest 10 published posts with this tag
|
||||
sql_posts = """
|
||||
SELECT p.title, p.featured_image, p.slug, p.excerpt, p.created_date
|
||||
SELECT p.title, p.featured_image, p.slug, p.excerpt, p.created_date, p.content
|
||||
FROM sis_post p
|
||||
JOIN sis_post_tag pt ON p.id = pt.post_id
|
||||
WHERE pt.tag_id = :tag_id AND p.status = 'PUBLISHED'
|
||||
@@ -38,13 +38,24 @@ try:
|
||||
posts = cursor.fetchall()
|
||||
|
||||
import random
|
||||
import re
|
||||
|
||||
def extract_words(html_text, num_words=100):
|
||||
if not html_text:
|
||||
return ""
|
||||
if hasattr(html_text, 'read'):
|
||||
html_text = html_text.read()
|
||||
text = re.sub('<[^<]+?>', ' ', html_text)
|
||||
words = text.split()
|
||||
return " ".join(words[:num_words]) + ("..." if len(words) > num_words else "")
|
||||
|
||||
prices = ["42.400.000 VNĐ", "23.000.000 VNĐ", "32.500.000 VNĐ", "15.000.000 VNĐ", "Liên hệ"]
|
||||
badges = ["SAT", "SUN", "MON", "TUE", "WED", "THU", "FRI"]
|
||||
|
||||
items = []
|
||||
for post in posts:
|
||||
img = post[1] if post[1] else ""
|
||||
desc = post[3] if post[3] else ""
|
||||
desc = post[3] if post[3] else extract_words(post[5], 100)
|
||||
date_val = post[4]
|
||||
date_str = date_val.strftime("%d/%m/%Y") if hasattr(date_val, 'strftime') else str(date_val) if date_val else "12/09/2026"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
package com.sisvietnamvn.web.config;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import com.sisvietnamvn.web.repository.PageRepository;
|
||||
|
||||
@Component
|
||||
public class DebugRunner implements CommandLineRunner {
|
||||
@Autowired
|
||||
private PageRepository pageRepository;
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
long count = pageRepository.findAll().stream().filter(p -> "SPECIALTY_DETAIL".equals(p.getLayout().name())).count();
|
||||
System.out.println("TOTAL_SPECIALTIES_IN_DB: " + count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.sisvietnamvn.web.config;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Menu;
|
||||
import com.sisvietnamvn.web.domain.MenuItem;
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.domain.PageLayout;
|
||||
import com.sisvietnamvn.web.repository.MenuItemRepository;
|
||||
import com.sisvietnamvn.web.repository.MenuRepository;
|
||||
import com.sisvietnamvn.web.service.PageService;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class MenuSeeder implements CommandLineRunner {
|
||||
|
||||
private final MenuRepository menuRepository;
|
||||
private final MenuItemRepository menuItemRepository;
|
||||
private final PageService pageService;
|
||||
|
||||
public MenuSeeder(MenuRepository menuRepository, MenuItemRepository menuItemRepository, PageService pageService) {
|
||||
this.menuRepository = menuRepository;
|
||||
this.menuItemRepository = menuItemRepository;
|
||||
this.pageService = pageService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void run(String... args) {
|
||||
Menu primaryMenu = menuRepository.findAll().stream()
|
||||
.filter(m -> m.getName() != null && m.getName().trim().equals("primaryMenu"))
|
||||
.findFirst().orElse(null);
|
||||
|
||||
if (primaryMenu == null) return;
|
||||
|
||||
MenuItem chuyenKhoa = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> i.getLabel() != null && "CHUYÊN KHOA".equalsIgnoreCase(i.getLabel().trim()))
|
||||
.findFirst().orElse(null);
|
||||
|
||||
if (chuyenKhoa == null) return;
|
||||
|
||||
List<Page> specialties = pageService.findAll().stream()
|
||||
.filter(p -> PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int maxOrder = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> chuyenKhoa.equals(i.getParent()))
|
||||
.mapToInt(i -> i.getDisplayOrder() != null ? i.getDisplayOrder() : 0)
|
||||
.max().orElse(0);
|
||||
|
||||
for (Page spec : specialties) {
|
||||
boolean exists = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> chuyenKhoa.equals(i.getParent()))
|
||||
.anyMatch(i -> (i.getLabel() != null && i.getLabel().equalsIgnoreCase(spec.getTitle())) ||
|
||||
(i.getTitle() != null && i.getTitle().equalsIgnoreCase(spec.getTitle())));
|
||||
|
||||
if (!exists) {
|
||||
maxOrder++;
|
||||
MenuItem newItem = new MenuItem();
|
||||
newItem.setMenu(primaryMenu);
|
||||
newItem.setParent(chuyenKhoa);
|
||||
newItem.setLabel(spec.getTitle()); // Set label
|
||||
newItem.setTitle(spec.getTitle()); // Set title as well
|
||||
newItem.setUrl("/chuyen-khoa/" + spec.getSlug());
|
||||
newItem.setDisplayOrder(maxOrder);
|
||||
menuItemRepository.save(newItem);
|
||||
System.out.println("ADDED MENU ITEM: " + spec.getTitle());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -55,7 +55,7 @@ public class SecurityConfiguration {
|
||||
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
|
||||
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
|
||||
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
|
||||
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**", "/dao-tao/**",
|
||||
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**", "/dao-tao/**", "/chuyen-khoa", "/chuyen-khoa/**",
|
||||
"/Đào tạo tại UMC_files/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sisvietnamvn.web.controller;
|
||||
import com.sisvietnamvn.web.repository.PageRepository;
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
@RestController
|
||||
public class DebugController {
|
||||
@Autowired
|
||||
private PageRepository pageRepository;
|
||||
|
||||
@GetMapping("/api/debug-page")
|
||||
public String debugPage(@RequestParam String slug) {
|
||||
Page page = pageRepository.findBySlug(slug).orElse(null);
|
||||
if (page == null) return "PAGE NOT FOUND";
|
||||
String content = page.getContent();
|
||||
return "CONTENT_LENGTH: " + (content == null ? "NULL" : content.length()) + "\nCONTENT_PREVIEW: " + (content == null ? "NULL" : content);
|
||||
}
|
||||
}
|
||||
+121
-13
@@ -132,6 +132,44 @@ public class PageController {
|
||||
@GetMapping("/specialty")
|
||||
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
|
||||
|
||||
@GetMapping("/chuyen-khoa")
|
||||
public String getSpecialtyList(@org.springframework.web.bind.annotation.RequestParam(required = false) String category, Model model) {
|
||||
List<Page> allSpecialties = pageService.findAll().stream()
|
||||
.filter(p -> com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
|
||||
.filter(p -> com.sisvietnamvn.web.domain.PageStatus.PUBLISHED.equals(p.getStatus()) || com.sisvietnamvn.web.domain.PageStatus.DRAFT.equals(p.getStatus()))
|
||||
.sorted((a, b) -> {
|
||||
String ta = a.getTitle() != null ? a.getTitle() : "";
|
||||
String tb = b.getTitle() != null ? b.getTitle() : "";
|
||||
return ta.compareToIgnoreCase(tb);
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
java.util.function.Predicate<Page> catFilter = p -> true;
|
||||
if ("khoa-lam-sang".equals(category)) {
|
||||
catFilter = p -> "KHOA_LAM_SANG".equals(p.getSpecialtyCategory());
|
||||
} else if ("khoa-can-lam-sang".equals(category)) {
|
||||
catFilter = p -> "KHOA_CAN_LAM_SANG".equals(p.getSpecialtyCategory());
|
||||
} else if ("khoa-ho-tro-lam-sang".equals(category)) {
|
||||
catFilter = p -> "KHOA_HO_TRO_LAM_SANG".equals(p.getSpecialtyCategory());
|
||||
}
|
||||
|
||||
List<Page> filtered = allSpecialties.stream().filter(catFilter).collect(java.util.stream.Collectors.toList());
|
||||
|
||||
model.addAttribute("specialties", filtered);
|
||||
model.addAttribute("currentCategory", category != null ? category : "all");
|
||||
model.addAttribute("countAll", allSpecialties.size());
|
||||
model.addAttribute("countLamSang", allSpecialties.stream().filter(p -> "KHOA_LAM_SANG".equals(p.getSpecialtyCategory())).count());
|
||||
model.addAttribute("countCanLamSang", allSpecialties.stream().filter(p -> "KHOA_CAN_LAM_SANG".equals(p.getSpecialtyCategory())).count());
|
||||
model.addAttribute("countHoTro", allSpecialties.stream().filter(p -> "KHOA_HO_TRO_LAM_SANG".equals(p.getSpecialtyCategory())).count());
|
||||
return "pages/specialty-list";
|
||||
}
|
||||
|
||||
@GetMapping("/chuyen-khoa/{slug}")
|
||||
public String getSpecialtyDetail(@PathVariable String slug, Model model) {
|
||||
model.addAttribute("activeTheme", "umass"); // Consistent with other routes
|
||||
return renderPage(pageService.findBySlug(slug), model);
|
||||
}
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.sisvietnamvn.web.repository.TagRepository tagRepository;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
@@ -174,6 +212,54 @@ public class PageController {
|
||||
return "Seeded " + items.size() + " posts!";
|
||||
}
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.sisvietnamvn.web.repository.MenuItemRepository menuItemRepository;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.sisvietnamvn.web.repository.MenuRepository menuRepository;
|
||||
|
||||
@GetMapping("/run-add-specialties")
|
||||
@org.springframework.web.bind.annotation.ResponseBody
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public String addSpecialtiesToMenu() {
|
||||
com.sisvietnamvn.web.domain.Menu primaryMenu = menuRepository.findByName("primaryMenu").orElse(null);
|
||||
if (primaryMenu == null) return "Primary menu not found";
|
||||
|
||||
com.sisvietnamvn.web.domain.MenuItem chuyenKhoa = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> "CHUYÊN KHOA".equalsIgnoreCase(i.getTitle()))
|
||||
.findFirst().orElse(null);
|
||||
|
||||
if (chuyenKhoa == null) return "CHUYÊN KHOA menu item not found";
|
||||
|
||||
List<com.sisvietnamvn.web.domain.Page> specialties = pageService.findAll().stream()
|
||||
.filter(p -> com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
int maxOrder = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> chuyenKhoa.equals(i.getParent()))
|
||||
.mapToInt(i -> i.getDisplayOrder() != null ? i.getDisplayOrder() : 0)
|
||||
.max().orElse(0);
|
||||
|
||||
int count = 0;
|
||||
for (com.sisvietnamvn.web.domain.Page spec : specialties) {
|
||||
boolean exists = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> chuyenKhoa.equals(i.getParent()))
|
||||
.anyMatch(i -> i.getTitle().equalsIgnoreCase(spec.getTitle()));
|
||||
|
||||
if (!exists) {
|
||||
maxOrder++;
|
||||
com.sisvietnamvn.web.domain.MenuItem newItem = new com.sisvietnamvn.web.domain.MenuItem();
|
||||
newItem.setMenu(primaryMenu);
|
||||
newItem.setParent(chuyenKhoa);
|
||||
newItem.setTitle(spec.getTitle());
|
||||
newItem.setUrl("/chuyen-khoa/" + spec.getSlug());
|
||||
newItem.setDisplayOrder(maxOrder);
|
||||
menuItemRepository.save(newItem);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return "Added " + count + " specialties to CHUYÊN KHOA menu.";
|
||||
}
|
||||
|
||||
@GetMapping("/doctor")
|
||||
@org.springframework.transaction.annotation.Transactional(readOnly = true)
|
||||
public String getDoctor(Model model) {
|
||||
@@ -273,24 +359,42 @@ public class PageController {
|
||||
}
|
||||
|
||||
// Parse Editor.js content JSON to extract blocks
|
||||
List<Map<String, Object>> blocks = Collections.emptyList();
|
||||
List<Map<String, Object>> blocks = new java.util.ArrayList<>();
|
||||
if (page.getContent() != null && !page.getContent().trim().isEmpty()) {
|
||||
try {
|
||||
Map<String, Object> editorData = objectMapper.readValue(page.getContent(), new TypeReference<>() {});
|
||||
if (editorData.containsKey("blocks")) {
|
||||
blocks = (List<Map<String, Object>>) editorData.get("blocks");
|
||||
for (Map<String, Object> block : blocks) {
|
||||
if ("snippet".equals(block.get("type"))) {
|
||||
Map<String, Object> data = (Map<String, Object>) block.get("data");
|
||||
if (data != null && data.containsKey("id")) {
|
||||
String snippetId = (String) data.get("id");
|
||||
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
|
||||
String contentTrimmed = page.getContent().trim();
|
||||
if (contentTrimmed.startsWith("{")) {
|
||||
try {
|
||||
Map<String, Object> editorData = objectMapper.readValue(contentTrimmed, new TypeReference<>() {});
|
||||
if (editorData.containsKey("blocks")) {
|
||||
blocks = (List<Map<String, Object>>) editorData.get("blocks");
|
||||
for (Map<String, Object> block : blocks) {
|
||||
if ("snippet".equals(block.get("type"))) {
|
||||
Map<String, Object> data = (Map<String, Object>) block.get("data");
|
||||
if (data != null && data.containsKey("id")) {
|
||||
String snippetId = (String) data.get("id");
|
||||
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
LOG.error("Failed to parse Editor.js JSON for page ID: {}", page.getId(), e);
|
||||
// Fallback to raw HTML on error
|
||||
Map<String, Object> rawBlock = new java.util.HashMap<>();
|
||||
rawBlock.put("type", "raw");
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("html", contentTrimmed);
|
||||
rawBlock.put("data", data);
|
||||
blocks.add(rawBlock);
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
LOG.error("Failed to parse Editor.js JSON for page ID: {}", page.getId(), e);
|
||||
} else {
|
||||
// Fallback to raw HTML
|
||||
Map<String, Object> rawBlock = new java.util.HashMap<>();
|
||||
rawBlock.put("type", "raw");
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("html", contentTrimmed);
|
||||
rawBlock.put("data", data);
|
||||
blocks.add(rawBlock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +405,10 @@ public class PageController {
|
||||
return "pages/contact-us";
|
||||
}
|
||||
|
||||
if (com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(page.getLayout())) {
|
||||
return "pages/specialty-detail";
|
||||
}
|
||||
|
||||
return "page";
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,21 @@ public class Page extends AbstractAuditingEntity<Long> {
|
||||
@Column(name = "layout", length = 20, nullable = false)
|
||||
private PageLayout layout = PageLayout.STANDARD;
|
||||
|
||||
@Column(name = "contact_email", length = 255)
|
||||
private String contactEmail;
|
||||
|
||||
@Column(name = "contact_address", length = 500)
|
||||
private String contactAddress;
|
||||
|
||||
@Column(name = "contact_phone", length = 100)
|
||||
private String contactPhone;
|
||||
|
||||
@Column(name = "hero_image", length = 1000)
|
||||
private String heroImage;
|
||||
|
||||
@Column(name = "specialty_category", length = 50)
|
||||
private String specialtyCategory;
|
||||
|
||||
// --- Getters and Setters ---
|
||||
|
||||
@Override
|
||||
@@ -133,6 +148,46 @@ public class Page extends AbstractAuditingEntity<Long> {
|
||||
this.layout = layout;
|
||||
}
|
||||
|
||||
public String getContactEmail() {
|
||||
return contactEmail;
|
||||
}
|
||||
|
||||
public void setContactEmail(String contactEmail) {
|
||||
this.contactEmail = contactEmail;
|
||||
}
|
||||
|
||||
public String getContactAddress() {
|
||||
return contactAddress;
|
||||
}
|
||||
|
||||
public void setContactAddress(String contactAddress) {
|
||||
this.contactAddress = contactAddress;
|
||||
}
|
||||
|
||||
public String getContactPhone() {
|
||||
return contactPhone;
|
||||
}
|
||||
|
||||
public void setContactPhone(String contactPhone) {
|
||||
this.contactPhone = contactPhone;
|
||||
}
|
||||
|
||||
public String getHeroImage() {
|
||||
return heroImage;
|
||||
}
|
||||
|
||||
public void setHeroImage(String heroImage) {
|
||||
this.heroImage = heroImage;
|
||||
}
|
||||
|
||||
public String getSpecialtyCategory() {
|
||||
return specialtyCategory;
|
||||
}
|
||||
|
||||
public void setSpecialtyCategory(String specialtyCategory) {
|
||||
this.specialtyCategory = specialtyCategory;
|
||||
}
|
||||
|
||||
// --- equals, hashCode, toString ---
|
||||
|
||||
@Override
|
||||
|
||||
@@ -6,5 +6,6 @@ package com.sisvietnamvn.web.domain;
|
||||
public enum PageLayout {
|
||||
STANDARD,
|
||||
SIDEBAR,
|
||||
FULL_WIDTH
|
||||
FULL_WIDTH,
|
||||
SPECIALTY_DETAIL
|
||||
}
|
||||
|
||||
+1
-1
@@ -261,7 +261,7 @@ public class SwiperSliderPlugin {
|
||||
return "<div class=\"swiper-slide max-w-[789px] !mr-2\" style=\"width: 789px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[789/460] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
||||
}
|
||||
if ("course-card".equals(slug)) {
|
||||
return "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 18px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">{{badge}}</span></div></div></div><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></article></div>";
|
||||
return "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px; text-align: left;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 20px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5; overflow: hidden;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">({{badge}})</span></div></div></div><div class=\"btn\"><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></div></article></div>";
|
||||
}
|
||||
return "<div class=\"swiper-slide\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
||||
}
|
||||
|
||||
+1
@@ -9,4 +9,5 @@ import org.springframework.stereotype.Repository;
|
||||
*/
|
||||
@Repository
|
||||
public interface MenuItemRepository extends JpaRepository<MenuItem, Long> {
|
||||
java.util.List<MenuItem> findByMenu_Id(Long menuId);
|
||||
}
|
||||
|
||||
@@ -21,4 +21,7 @@ public interface MenuRepository extends JpaRepository<Menu, Long> {
|
||||
|
||||
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"items", "items.children"})
|
||||
Optional<Menu> findByLocation(String location);
|
||||
|
||||
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"items", "items.children"})
|
||||
Optional<Menu> findByName(String name);
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="20260728140500-1" author="system">
|
||||
<addColumn tableName="sis_page">
|
||||
<column name="contact_email" type="varchar(255)"/>
|
||||
<column name="contact_address" type="varchar(500)"/>
|
||||
<column name="contact_phone" type="varchar(100)"/>
|
||||
<column name="hero_image" type="varchar(1000)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="20260728160000-1" author="system">
|
||||
<addColumn tableName="sis_page">
|
||||
<column name="specialty_category" type="varchar(50)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="20260728160000-2" author="system">
|
||||
<!-- Khoa lâm sàng -->
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-cap-cuu'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-than-kinh-dot-quy'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'tim-mach'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-ngoai-tong-hop'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-kham-benh'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'phau-thuat-gay-me-hoi-suc'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'don-vi-can-thiep-mach-dsa'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'don-vi-cap-cuu-ngoai-vien'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'chuyen-khoa-tai-mui-hong'</where></update>
|
||||
|
||||
<!-- Khoa cận lâm sàng -->
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-chan-doan-hinh-anh'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-xet-nghiem'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang'</where></update>
|
||||
|
||||
<!-- Khoa hỗ trợ lâm sàng -->
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'khoa-duoc'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'khoa-dinh-duong-tiet-che'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'don-vi-kiem-soat-nhiem-khuan'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'don-vi-kham-suc-khoe-ngoai-vien'</where></update>
|
||||
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'phong-quan-ly-van-hanh'</where></update>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -50,4 +50,6 @@
|
||||
<include file="config/liquibase/changelog/20260723185500_add_doctor_schedule.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260724150000_alter_setting_value.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260724151500_alter_setting_value_to_clob.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260728140500_add_contact_fields_to_page.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260728160000_add_specialty_category.xml" relativeToChangelogFile="false"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -1213,12 +1213,19 @@ figure.table table tr:hover {
|
||||
}
|
||||
|
||||
.education {
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex !important;
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.rounded-full {
|
||||
justify-content: center;
|
||||
background-color: var(--color-white);
|
||||
@@ -1227,10 +1234,27 @@ figure.table table tr:hover {
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-navigation {
|
||||
border-radius: 24px;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-white);
|
||||
color: var(--color-black);
|
||||
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
svg {
|
||||
color: var(--color-old-brick);
|
||||
}
|
||||
svg:hover{
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.training-sidebar {
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.text-white {
|
||||
margin-bottom: 4px;
|
||||
|
||||
@@ -1245,4 +1269,21 @@ figure.table table tr:hover {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swiper-slide {
|
||||
border: solid 1px #efeff0;
|
||||
border-radius: 15px;
|
||||
padding: 1rem;
|
||||
.btn {
|
||||
padding: 0.75rem;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.flex.items-center {
|
||||
justify-content: center;
|
||||
}
|
||||
a {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
:root{--swiper-navigation-size:44px}.swiper-button-next,.swiper-button-prev{position:absolute;top:var(--swiper-navigation-top-offset,50%);width:calc(var(--swiper-navigation-size) / 44 * 27);height:var(--swiper-navigation-size);margin-top:calc(0px - (var(--swiper-navigation-size) / 2));z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--swiper-navigation-color,var(--swiper-theme-color))}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-next.swiper-button-hidden,.swiper-button-prev.swiper-button-hidden{opacity:0;cursor:auto;pointer-events:none}.swiper-navigation-disabled .swiper-button-next,.swiper-navigation-disabled .swiper-button-prev{display:none!important}.swiper-button-next svg,.swiper-button-prev svg{width:100%;height:100%;object-fit:contain;transform-origin:center}.swiper-rtl .swiper-button-next svg,.swiper-rtl .swiper-button-prev svg{transform:rotate(180deg)}.swiper-button-prev,.swiper-rtl .swiper-button-next{left:var(--swiper-navigation-sides-offset,10px);right:auto}.swiper-button-lock{display:none}.swiper-button-next:after,.swiper-button-prev:after{font-family:swiper-icons;font-size:var(--swiper-navigation-size);text-transform:none!important;letter-spacing:0;font-variant:normal;line-height:1}.swiper-button-prev:after,.swiper-rtl .swiper-button-next:after{content:"prev"}.swiper-button-next,.swiper-rtl .swiper-button-prev{right:var(--swiper-navigation-sides-offset,10px);left:auto}.swiper-button-next:after,.swiper-rtl .swiper-button-prev:after{content:"next"}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
/* PLEASE DO NOT COPY AND PASTE THIS CODE. */(function(){var w=window,C='___grecaptcha_cfg',cfg=w[C]=w[C]||{},N='grecaptcha';var gr=w[N]=w[N]||{};gr.ready=gr.ready||function(f){(cfg['fns']=cfg['fns']||[]).push(f);};w['__recaptcha_api']='https://www.google.com/recaptcha/api2/';(cfg['render']=cfg['render']||[]).push('onload');(cfg['anchor-ms']=cfg['anchor-ms']||[]).push(20000);(cfg['execute-ms']=cfg['execute-ms']||[]).push(30000);w['__google_recaptcha_client']=true;var d=document,po=d.createElement('script');po.type='text/javascript';po.async=true; po.charset='utf-8';po.src='https://www.gstatic.com/recaptcha/releases/A7KpaEASfhDcK0nXxgQEyyYv/recaptcha__en_gb.js';po.crossOrigin='anonymous';po.integrity='sha384-5OOK2erh/YOEG9kGHGnFP3+VW3JJ6xFMntvj2Hukmf0CErQumRfz6RUGAs6A/57r';var e=d.querySelector('script[nonce]'),n=e&&(e['nonce']||e.getAttribute('nonce'));if(n){po.setAttribute('nonce',n);}var s=d.getElementsByTagName('script')[0];s.parentNode.insertBefore(po, s);})();
|
||||
+1
@@ -0,0 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[7358],{24473:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,14933,23)),Promise.resolve().then(n.t.bind(n,86695,23)),Promise.resolve().then(n.t.bind(n,54775,23)),Promise.resolve().then(n.t.bind(n,22908,23)),Promise.resolve().then(n.t.bind(n,23624,23)),Promise.resolve().then(n.t.bind(n,59440,23)),Promise.resolve().then(n.t.bind(n,62920,23)),Promise.resolve().then(n.t.bind(n,19710,23))}},e=>{var s=s=>e(e.s=s);e.O(0,[1032,4619],()=>(s(36596),s(24473))),_N_E=e.O()}]);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(r,i){"use strict";var e,o=r.location,s=r.document,t=s.querySelector('[src*="'+i+'"]'),l=t&&t.getAttribute("data-domain"),p=r.localStorage.plausible_ignore;function c(e){console.warn("Ignoring Event: "+e)}function a(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return c("localhost");if(!(r.phantom||r._phantom||r.__nightmare||r.navigator.webdriver||r.Cypress)){if("true"==p)return c("localStorage flag");var a={};a.n=e,a.u=o.href,a.d=l,a.r=s.referrer||null,a.w=r.innerWidth,t&&t.meta&&(a.m=JSON.stringify(t.meta)),t&&t.props&&(a.p=JSON.stringify(t.props));var n=new XMLHttpRequest;n.open("POST",i+"/api/event",!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(a)),n.onreadystatechange=function(){4==n.readyState&&t&&t.callback&&t.callback()}}}function n(){e!==o.pathname&&(e=o.pathname,a("pageview"))}try{var u,h=r.history;h.pushState&&(u=h.pushState,h.pushState=function(){u.apply(this,arguments),n()},r.addEventListener("popstate",n));var g=r.plausible&&r.plausible.q||[];r.plausible=a;for(var f=0;f<g.length;f++)a.apply(this,g[f]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){e||"visible"!==s.visibilityState||n()}):n()}catch(e){console.error(e),(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"https://analytics.jamstackvietnam.com");
|
||||
+1
File diff suppressed because one or more lines are too long
+1710
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -186,22 +186,20 @@
|
||||
cursor: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
[class*='btn-navigation-']:hover:not([disabled]):not(.swiper-button-disabled):not(.swiper-button-lock) {
|
||||
background-color: var(--color-primary-600);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Related Courses (Chương trình khác) -->
|
||||
<section class="py-6 xl:py-12 md:py-8 bg-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="container mx-auto px-4" style="text-align: center">
|
||||
<h2 class="display-7 text-primary-600 text-center xl:mb-8 md:mb-6 mb-4">Chương trình khác</h2>
|
||||
<div class="relative md:flex md:items-center">
|
||||
<div class="relative flex items-center gap-3">
|
||||
<button
|
||||
class="btn-navigation flex-shrink-0 lg:mr-4 mr-2 !relative z-10 md:size-[42px] size-[32px] items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group btn-navigation-chuong-trinh-khac-prev md:!flex hidden"
|
||||
class="btn-navigation flex-shrink-0 lg:mr-4 mr-2 z-10 flex items-center justify-center btn-navigation-chuong-trinh-khac-prev"
|
||||
style="width: 42px; height: 42px; min-width: 42px; flex-shrink: 0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-chevron-up size-4 -rotate-90"
|
||||
class="lucide lucide-chevron-up size-4"
|
||||
style="transform: rotate(-90deg)"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -215,19 +213,19 @@
|
||||
<path d="m18 15-6-6-6 6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="swiper swiper-chuong-trinh-khac [&>.swiper-pagination]:!static [&>.swiper-pagination]:mt-1 lg:[&>.swiper-pagination]:!hidden w-full"
|
||||
>
|
||||
<div class="swiper swiper-chuong-trinh-khac w-full" style="overflow: hidden">
|
||||
<div class="swiper-wrapper">
|
||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'chuong-trinh-khac')}"></th:block>
|
||||
</div>
|
||||
<div class="swiper-pagination"></div>
|
||||
<div class="swiper-pagination hidden"></div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-navigation flex-shrink-0 lg:ml-4 ml-2 !relative z-10 md:size-[42px] size-[32px] items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group btn-navigation-chuong-trinh-khac-next md:!flex hidden"
|
||||
class="btn-navigation flex-shrink-0 lg:ml-4 ml-2 z-10 flex items-center justify-center btn-navigation-chuong-trinh-khac-next"
|
||||
style="width: 42px; height: 42px; min-width: 42px; flex-shrink: 0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down size-4 -rotate-90"
|
||||
class="lucide lucide-chevron-down size-4"
|
||||
style="transform: rotate(-90deg)"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -180,6 +180,42 @@
|
||||
title.</small>
|
||||
</div>
|
||||
|
||||
<!-- Specialty/Contact Info -->
|
||||
<div class="card bg-light mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="font-weight-bold text-primary mb-3">Thông tin liên hệ & Banner (Dành riêng cho trang Chuyên khoa)</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="contactEmail" class="font-weight-bold">Email</label>
|
||||
<input type="text" class="form-control" id="contactEmail" th:field="*{contactEmail}" placeholder="vd: capcuu@umc.edu.vn">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="contactPhone" class="font-weight-bold">Điện thoại / SĐT</label>
|
||||
<input type="text" class="form-control" id="contactPhone" th:field="*{contactPhone}" placeholder="vd: 028 3952 5115">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="contactAddress" class="font-weight-bold">Địa chỉ</label>
|
||||
<input type="text" class="form-control" id="contactAddress" th:field="*{contactAddress}" placeholder="vd: Tầng trệt - Khu A">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="heroImage" class="font-weight-bold">Ảnh Banner (Hero Image URL)</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="heroImage" th:field="*{heroImage}" placeholder="URL ảnh">
|
||||
<div class="input-group-append">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('mediaManagerBtn').click()">
|
||||
<i class="fas fa-image"></i> Chọn ảnh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Editor Content -->
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
@@ -249,6 +285,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/underline@1.1.0/dist/bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.9.0/dist/image.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/attaches@1.3.0/dist/bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/raw@2.4.3/dist/bundle.js"></script>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,302 @@
|
||||
<!doctype html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='specialty-list-page')}"
|
||||
>
|
||||
<head>
|
||||
<title>Chuyên khoa - Bệnh viện S.I.S Cần Thơ</title>
|
||||
<meta name="description" content="Danh sách các chuyên khoa tại Bệnh viện Đa khoa Quốc tế S.I.S Cần Thơ" />
|
||||
|
||||
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/b6b2bf2d3af810a6.css}" />
|
||||
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/3cd83cfe34ca397f.css}" />
|
||||
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/45d4f6442d75f756.css}" />
|
||||
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/c37340727ca4fe15.css}" />
|
||||
|
||||
<style>
|
||||
.specialty-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.specialty-card:hover {
|
||||
background: var(--color-old-brick);
|
||||
color: white;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 25px rgba(0, 84, 166, 0.25);
|
||||
}
|
||||
.specialty-card:hover .specialty-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.specialty-card:hover .specialty-icon svg {
|
||||
color: white;
|
||||
}
|
||||
.specialty-card:hover .specialty-name {
|
||||
color: white;
|
||||
}
|
||||
.specialty-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: #f0f7ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.specialty-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--color-old-brick);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.specialty-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-old-brick);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.filter-btn:hover {
|
||||
border-color: var(--color-primary-600, #0054a6);
|
||||
color: var(--color-primary-600, #0054a6);
|
||||
}
|
||||
.filter-btn.active {
|
||||
background: var(--color-primary-600, #0054a6);
|
||||
color: white;
|
||||
border-color: var(--color-primary-600, #0054a6);
|
||||
}
|
||||
.filter-btn .count {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.filter-btn.active .count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary-600, #0054a6);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--color-primary-600, #0054a6);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.specialty-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
.specialty-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.specialty-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.specialty-name {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<section class="xl:py-12 md:py-8 py-6" style="background: #f6f6f6">
|
||||
<div class="container mx-auto px-4">
|
||||
<!-- Page Title -->
|
||||
<h1 class="display-4 text-primary-600 text-center xl:mb-8 md:mb-6 mb-4">Chuyên khoa</h1>
|
||||
|
||||
<!-- Filter Dropdown -->
|
||||
<div
|
||||
id="specialties_sel"
|
||||
class="flex justify-center xl:mb-10 md:mb-7 mb-5 relative w-full md:max-w-[320px] mx-auto xl:mb-10 md:mb-7 mb-5 title-4"
|
||||
>
|
||||
<select
|
||||
onchange="window.location.href = this.value"
|
||||
class="w-full group bg-white rounded-lg border lg:hover:border-primary-600 cursor-pointer xl:px-6 px-4 py-3 flex items-center justify-between lg:duration-150 shadow border-gray-200"
|
||||
>
|
||||
<option th:value="@{/chuyen-khoa}" th:selected="${currentCategory == 'all'}">Tất cả ([[${countAll}]])</option>
|
||||
<option th:value="@{/chuyen-khoa(category='khoa-lam-sang')}" th:selected="${currentCategory == 'khoa-lam-sang'}">
|
||||
Khoa lâm sàng ([[${countLamSang}]])
|
||||
</option>
|
||||
<option th:value="@{/chuyen-khoa(category='khoa-can-lam-sang')}" th:selected="${currentCategory == 'khoa-can-lam-sang'}">
|
||||
Khoa cận lâm sàng ([[${countCanLamSang}]])
|
||||
</option>
|
||||
<option
|
||||
th:value="@{/chuyen-khoa(category='khoa-ho-tro-lam-sang')}"
|
||||
th:selected="${currentCategory == 'khoa-ho-tro-lam-sang'}"
|
||||
>
|
||||
Khoa hỗ trợ lâm sàng ([[${countHoTro}]])
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Specialty Grid -->
|
||||
<div class="grid lg:grid-cols-4 md:grid-cols-3 grid-cols-2 xl:gap-8 md:gap-6 gap-4">
|
||||
<a th:each="spec : ${specialties}" th:href="@{/chuyen-khoa/{slug}(slug=${spec.slug})}" class="specialty-card">
|
||||
<div class="specialty-icon">
|
||||
<th:block th:switch="${spec.slug}">
|
||||
<!-- chuyen-khoa-tai-mui-hong -->
|
||||
<th:block th:case="'chuyen-khoa-tai-mui-hong'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Taimuihong-blue.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- don-vi-kiem-soat-nhiem-khuan -->
|
||||
<th:block th:case="'don-vi-kiem-soat-nhiem-khuan'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-29.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- khoa-cap-cuu -->
|
||||
<th:block th:case="'khoa-cap-cuu'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-37.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- khoa-chan-doan-hinh-anh -->
|
||||
<th:block th:case="'khoa-chan-doan-hinh-anh'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-3.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- khoa-dinh-duong-tiet-che -->
|
||||
<th:block th:case="'khoa-dinh-duong-tiet-che'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-9.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- khoa-duoc -->
|
||||
<th:block th:case="'khoa-duoc'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-11.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- khoa-kham-benh -->
|
||||
<th:block th:case="'khoa-kham-benh'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-27.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- khoa-than-kinh-dot-quy -->
|
||||
<th:block th:case="'khoa-than-kinh-dot-quy'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Thankinh-blue.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang -->
|
||||
<th:block th:case="'khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Phuchoichucnang-blue.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- khoa-xet-nghiem -->
|
||||
<th:block th:case="'khoa-xet-nghiem'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Xetnghiem-blue.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- phau-thuat-gay-me-hoi-suc -->
|
||||
<th:block th:case="'phau-thuat-gay-me-hoi-suc'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-13.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- tim-mach -->
|
||||
<th:block th:case="'tim-mach'">
|
||||
<img
|
||||
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Timmachcanthiep-blue.png"
|
||||
alt="Icon"
|
||||
class="w-12 h-12 object-contain mx-auto"
|
||||
/>
|
||||
</th:block>
|
||||
<!-- Default -->
|
||||
<th:block th:case="*">
|
||||
<div class="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 mx-auto">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6" />
|
||||
</svg>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
<span class="specialty-name" th:text="${spec.title}">Tên chuyên khoa</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div th:if="${#lists.isEmpty(specialties)}" class="text-center py-12">
|
||||
<p class="text-gray-500 text-lg">Không tìm thấy chuyên khoa nào trong nhóm này.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,153 @@
|
||||
import oracledb
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
def transform_html(title, raw_html):
|
||||
soup = BeautifulSoup(raw_html, "html.parser")
|
||||
|
||||
# Extract all images
|
||||
images = []
|
||||
for img in soup.find_all('img'):
|
||||
src = img.get('src')
|
||||
if src:
|
||||
images.append(src)
|
||||
img.decompose() # Remove from flow
|
||||
|
||||
hero_image = images[0] if len(images) > 0 else "https://sisvietnam.vn/wp-content/uploads/2021/07/DSC07517-1024x576.jpg"
|
||||
other_images = images[1:]
|
||||
|
||||
# Extract Contact Info
|
||||
text = soup.get_text()
|
||||
email_match = re.search(r"(?i)Email\s*:\s*([\w\.-]+@[\w\.-]+)", text)
|
||||
phone_match = re.search(r"(?i)(?:SĐT|Điện thoại|ĐT)\s*:\s*([\d\.\s-]+)", text)
|
||||
address_match = re.search(r"(?i)Địa chỉ\s*:\s*([^\n]+)", text)
|
||||
|
||||
contact_email = email_match.group(1).strip() if email_match else None
|
||||
contact_phone = phone_match.group(1).strip() if phone_match else None
|
||||
contact_address = address_match.group(1).strip() if address_match else None
|
||||
|
||||
# Parse sections based on wp:list or headings
|
||||
sections_data = []
|
||||
|
||||
top_uls = soup.find_all('ul', recursive=False)
|
||||
if not top_uls and soup.find('div', class_='wp-content-container'):
|
||||
top_uls = soup.find('div', class_='wp-content-container').find_all('ul', recursive=False)
|
||||
|
||||
for ul in top_uls:
|
||||
heading = "Nội dung"
|
||||
strong_tag = ul.find('strong')
|
||||
if strong_tag:
|
||||
heading = strong_tag.text.strip()
|
||||
strong_tag.decompose()
|
||||
|
||||
content_html = ""
|
||||
for li in ul.find_all('li', recursive=False):
|
||||
inner_uls = li.find_all('ul')
|
||||
for inner in inner_uls:
|
||||
content_html += str(inner)
|
||||
|
||||
if not content_html:
|
||||
content_html = str(ul)
|
||||
|
||||
sections_data.append({
|
||||
'heading': heading,
|
||||
'content': content_html
|
||||
})
|
||||
|
||||
if not sections_data:
|
||||
sections_data.append({
|
||||
'heading': 'Thông tin chi tiết',
|
||||
'content': str(soup)
|
||||
})
|
||||
|
||||
out = ""
|
||||
bg_colors = ["bg-white", "bg-gray-50", "bg-white", "bg-gray-100"]
|
||||
for i, sec in enumerate(sections_data):
|
||||
bg = bg_colors[i % len(bg_colors)]
|
||||
|
||||
img_html = ""
|
||||
if i < len(other_images):
|
||||
img_src = other_images[i]
|
||||
img_html = f"""
|
||||
<div class="w-full">
|
||||
<div class="rounded-2xl overflow-hidden shadow-lg aspect-[4/3] relative">
|
||||
<img src="{img_src}" class="absolute inset-0 w-full h-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
out += f"""
|
||||
<!-- SECTION {i+1} -->
|
||||
<section class="xl:py-12 md:py-8 py-4 {bg}">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex flex-col gap-6 lg:items-center">
|
||||
<div class="w-full">
|
||||
<h2 class="display-6 text-primary-600 mb-4 md:mb-6 xl:mb-8">{sec['heading']}</h2>
|
||||
<div class="prose max-w-none text-gray-700 body-2" style="list-style-type: disc;">
|
||||
{sec['content']}
|
||||
</div>
|
||||
</div>
|
||||
{img_html}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
return out, hero_image, contact_email, contact_address, contact_phone
|
||||
|
||||
def main():
|
||||
dsn = "localhost:1521/sisvietnam"
|
||||
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT slug, title, content FROM sis_page WHERE page_type = 'CUSTOM'")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
count = 0
|
||||
warnings = []
|
||||
|
||||
for row in rows:
|
||||
slug = row[0]
|
||||
title = row[1]
|
||||
raw_content = row[2].read() if hasattr(row[2], "read") else row[2]
|
||||
|
||||
import json
|
||||
if not raw_content:
|
||||
print(f"Skipping {slug} as content is empty/None.")
|
||||
continue
|
||||
|
||||
if raw_content.strip().startswith("{"):
|
||||
try:
|
||||
data = json.loads(raw_content)
|
||||
html_parts = []
|
||||
for b in data.get("blocks", []):
|
||||
if b.get("type") == "raw" or b.get("type") == "snippet":
|
||||
html_parts.append(b.get("data", {}).get("html", b.get("data", {}).get("htmlContent", "")))
|
||||
raw_content = "".join(html_parts)
|
||||
except Exception as e:
|
||||
print(f"Failed to parse JSON for {slug}: {e}")
|
||||
|
||||
transformed_content, hero_image, email, address, phone = transform_html(title, raw_content)
|
||||
|
||||
if "Thông tin chi tiết" in transformed_content and "wp-content-container" not in transformed_content:
|
||||
warnings.append(slug)
|
||||
|
||||
# Update DB
|
||||
update_sql = """
|
||||
UPDATE sis_page
|
||||
SET content = :1, hero_image = :2, contact_email = :3, contact_address = :4, contact_phone = :5
|
||||
WHERE slug = :6
|
||||
"""
|
||||
cursor.execute(update_sql, [transformed_content, hero_image, email, address, phone, slug])
|
||||
count += 1
|
||||
print(f"Updated {slug}")
|
||||
|
||||
conn.commit()
|
||||
print(f"Successfully transformed {count} pages.")
|
||||
|
||||
if warnings:
|
||||
print("\nWARNING: The following pages lacked standard WP lists (<ul>) and were migrated as a single flat section:")
|
||||
for w in warnings:
|
||||
print(f" - {w}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user