| @@ -2,6 +2,7 @@ | |||
| CREATE TABLE IF NOT EXISTS `pap_spider_news` ( | |||
| `id` int(11) NOT NULL AUTO_INCREMENT, | |||
| `origin_id` int(11) NOT NULL DEFAULT 0 COMMENT '原站文章ID', | |||
| `type` varchar(20) NOT NULL DEFAULT 'news' COMMENT '来源类型 news/about', | |||
| `title` varchar(500) NOT NULL DEFAULT '' COMMENT '文章标题', | |||
| `category` varchar(100) DEFAULT '' COMMENT '原站栏目分类', | |||
| `publish_time` datetime DEFAULT NULL COMMENT '原站发布时间', | |||
| @@ -16,6 +17,6 @@ CREATE TABLE IF NOT EXISTS `pap_spider_news` ( | |||
| `create_time` datetime DEFAULT CURRENT_TIMESTAMP, | |||
| `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |||
| PRIMARY KEY (`id`), | |||
| UNIQUE KEY `uk_origin_id` (`origin_id`), | |||
| UNIQUE KEY `uk_type_origin_id` (`type`, `origin_id`), | |||
| KEY `idx_status` (`status`) | |||
| ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='爬虫-新闻数据'; | |||
| @@ -0,0 +1,23 @@ | |||
| -- 将 pap_spider_news 中 id=119~126 的公益项目数据转入 pap_article,column_id=5 | |||
| -- content 优先取已转存的 content,其次取原始 content_original | |||
| INSERT INTO `pap_article` (`column_id`, `title`, `summary`, `content`, `cover`, `category`, `is_top`, `status`, `publish_time`, `sort`, `create_time`) | |||
| SELECT | |||
| 5 AS column_id, | |||
| s.title, | |||
| '' AS summary, | |||
| COALESCE(s.content, s.content_original, '') AS content, | |||
| COALESCE(s.cover, s.cover_original, '') AS cover, | |||
| s.category, | |||
| 0 AS is_top, | |||
| 1 AS status, | |||
| s.publish_time, | |||
| 0 AS sort, | |||
| NOW() | |||
| FROM `pap_spider_news` s | |||
| WHERE s.id IN (119, 120, 121, 122, 123, 124, 125, 126); | |||
| -- 回写 article_id 到 spider_news(方便追溯) | |||
| UPDATE `pap_spider_news` sn | |||
| INNER JOIN `pap_article` a ON a.title = sn.title AND a.column_id = 5 | |||
| SET sn.article_id = a.id, sn.status = 3 | |||
| WHERE sn.id IN (119, 120, 121, 122, 123, 124, 125, 126); | |||
| @@ -0,0 +1,4 @@ | |||
| -- 给spider_news表添加type字段,区分news和about | |||
| ALTER TABLE `pap_spider_news` ADD COLUMN `type` varchar(20) NOT NULL DEFAULT 'news' COMMENT '来源类型 news/about' AFTER `origin_id`; | |||
| ALTER TABLE `pap_spider_news` DROP INDEX `uk_origin_id`; | |||
| ALTER TABLE `pap_spider_news` ADD UNIQUE KEY `uk_type_origin_id` (`type`, `origin_id`); | |||
| @@ -0,0 +1,12 @@ | |||
| -- 栏目表扩展:增加前台展示字段 | |||
| ALTER TABLE `pap_column` ADD COLUMN `name_en` varchar(200) DEFAULT '' COMMENT '英文名称' AFTER `name`; | |||
| ALTER TABLE `pap_column` ADD COLUMN `description` varchar(500) DEFAULT '' COMMENT '栏目描述/副标题' AFTER `name_en`; | |||
| ALTER TABLE `pap_column` ADD COLUMN `banner_image` varchar(500) DEFAULT '' COMMENT '栏目Banner背景图' AFTER `description`; | |||
| -- 更新现有一级栏目数据 | |||
| UPDATE `pap_column` SET name_en = 'ABOUT US', description = '苍穹 笃行 创新 守止' WHERE `key` = 'about'; | |||
| UPDATE `pap_column` SET name_en = 'PUBLIC WELFARE PROJECTS', description = '聚焦健康公益,守护每一份希望' WHERE `key` = 'project'; | |||
| UPDATE `pap_column` SET name_en = 'PARTY BUILDING', description = '不忘初心 牢记使命' WHERE `key` = 'party'; | |||
| UPDATE `pap_column` SET name_en = 'INFORMATION DISCLOSURE', description = '公开透明 接受监督' WHERE `key` = 'disclosure'; | |||
| UPDATE `pap_column` SET name_en = 'NEWS CENTER', description = '了解基金会最新动态' WHERE `key` = 'news'; | |||
| UPDATE `pap_column` SET name_en = 'CONTACT US', description = '期待与您携手同行' WHERE `key` = 'contact'; | |||
| @@ -44,7 +44,8 @@ exports.model = { | |||
| port: '26821', | |||
| user: 'root', | |||
| password: '5orKUdDN3QhESVcS', | |||
| dateStrings: true | |||
| dateStrings: true, | |||
| connectionLimit: 10 | |||
| } | |||
| }; | |||
| @@ -3,6 +3,10 @@ module.exports = [ | |||
| ['/', 'index/index'], | |||
| ['/index', 'index/index'], | |||
| // 一级栏目页面(带子栏目) | |||
| ['/column/:key', 'column/index', 'get'], | |||
| ['/column/:key/:child', 'column/index', 'get'], | |||
| // 后台管理路由 | |||
| ['/admin/login', 'admin/auth/login', 'get'], | |||
| ['/admin/auth/login', 'admin/auth/doLogin', 'post'], | |||
| @@ -110,6 +114,7 @@ module.exports = [ | |||
| // 爬虫工具 | |||
| ['/admin/spider/fetch-list', 'admin/spider/fetchList'], | |||
| ['/admin/spider/fetch-about-list', 'admin/spider/fetchAboutList'], | |||
| ['/admin/spider/fetch-detail', 'admin/spider/fetchDetail'], | |||
| ['/admin/spider/transfer-images', 'admin/spider/transferImages'], | |||
| ['/admin/spider/sync', 'admin/spider/sync'], | |||
| @@ -20,18 +20,9 @@ module.exports = class extends Base { | |||
| // 待办事项(后续从数据库获取) | |||
| this.assign('todoList', []); | |||
| // 动态栏目菜单(后续从接口获取) | |||
| this.assign('columnMenus', await this.getColumnMenus()); | |||
| // 当前登录用户 | |||
| this.assign('adminUser', this.adminUser || {}); | |||
| return this.display(); | |||
| } | |||
| // 获取动态栏目菜单(暂时返回空,后续从数据库获取) | |||
| async getColumnMenus() { | |||
| // TODO: 从数据库获取栏目配置 | |||
| return []; | |||
| } | |||
| }; | |||
| @@ -26,7 +26,7 @@ module.exports = class extends Base { | |||
| async fetchListAction() { | |||
| try { | |||
| const res = await axios.post( | |||
| `${ORIGIN.listUrl}?act=contentlist&id=&type=news`, | |||
| `${ORIGIN.listUrl}?act=contentlist&id=&type=product`, | |||
| 'order=asc&limit=9999&offset=0', | |||
| { | |||
| headers: { | |||
| @@ -53,7 +53,7 @@ module.exports = class extends Base { | |||
| const publishTime = this.extractTime(row.c_addtime); | |||
| // 检查是否已存在 | |||
| const exists = await model.where({ origin_id: originId }).find(); | |||
| const exists = await model.where({ origin_id: originId, type: 'news' }).find(); | |||
| if (exists && exists.id) { | |||
| skipped++; | |||
| continue; | |||
| @@ -64,6 +64,7 @@ module.exports = class extends Base { | |||
| title, | |||
| category, | |||
| publish_time: publishTime, | |||
| type: 'product', | |||
| status: 0 | |||
| }); | |||
| inserted++; | |||
| @@ -80,6 +81,67 @@ module.exports = class extends Base { | |||
| } | |||
| } | |||
| /** | |||
| * 步骤1b:拉取about列表并入库 | |||
| * GET /admin/spider/fetch-about-list | |||
| * 参数: sid (可选,指定栏目sid) | |||
| */ | |||
| async fetchAboutListAction() { | |||
| try { | |||
| const sid = this.get('sid') || ''; | |||
| const res = await axios.post( | |||
| `${ORIGIN.listUrl}?act=aboutlist&sid=${sid}`, | |||
| 'order=asc&limit=9999&offset=0', | |||
| { | |||
| headers: { | |||
| Cookie: ORIGIN.cookie, | |||
| 'Content-Type': 'application/x-www-form-urlencoded' | |||
| } | |||
| } | |||
| ); | |||
| const rows = res.data; | |||
| if (!rows || !Array.isArray(rows)) { | |||
| return this.json({ code: 1, msg: 'about列表数据获取失败' }); | |||
| } | |||
| const model = this.model('spider_news'); | |||
| let inserted = 0; | |||
| let skipped = 0; | |||
| for (const row of rows) { | |||
| const originId = parseInt(row.id); | |||
| const title = this.extractTitle(row.a_name); | |||
| const category = row.sid || ''; | |||
| // 检查是否已存在 | |||
| const exists = await model.where({ origin_id: originId, type: 'about' }).find(); | |||
| if (exists && exists.id) { | |||
| skipped++; | |||
| continue; | |||
| } | |||
| await model.add({ | |||
| origin_id: originId, | |||
| title, | |||
| category, | |||
| type: 'about', | |||
| status: 0 | |||
| }); | |||
| inserted++; | |||
| } | |||
| return this.json({ | |||
| code: 0, | |||
| msg: `about列表拉取完成,新增${inserted}条,跳过${skipped}条`, | |||
| data: { total: rows.length, inserted, skipped } | |||
| }); | |||
| } catch (err) { | |||
| think.logger.error('拉取about列表失败:', err); | |||
| return this.json({ code: 1, msg: '拉取about列表失败: ' + err.message }); | |||
| } | |||
| } | |||
| /** | |||
| * 步骤2:爬取详情页内容 | |||
| * GET /admin/spider/fetch-detail | |||
| @@ -87,14 +149,19 @@ module.exports = class extends Base { | |||
| */ | |||
| async fetchDetailAction() { | |||
| const targetId = this.get('id'); | |||
| const type = this.get('type') || ''; | |||
| const model = this.model('spider_news'); | |||
| let list; | |||
| let where = { status: 0 }; | |||
| if (targetId) { | |||
| list = await model.where({ origin_id: targetId }).select(); | |||
| } else { | |||
| list = await model.where({ status: 0 }).select(); | |||
| where = { origin_id: targetId }; | |||
| if (type) where.type = type; | |||
| } | |||
| if (type && !targetId) { | |||
| where.type = type; | |||
| } | |||
| const list = await model.where(where).select(); | |||
| if (!list.length) { | |||
| return this.json({ code: 0, msg: '没有待爬取的记录' }); | |||
| @@ -105,7 +172,7 @@ module.exports = class extends Base { | |||
| for (const item of list) { | |||
| try { | |||
| const detailUrl = `${ORIGIN.base}/?news/${item.origin_id}.html`; | |||
| const detailUrl = `${ORIGIN.base}/?${type}/${item.origin_id}.html` | |||
| const res = await axios.get(detailUrl, { | |||
| headers: { Cookie: ORIGIN.cookie }, | |||
| responseType: 'arraybuffer' | |||
| @@ -168,15 +235,20 @@ module.exports = class extends Base { | |||
| */ | |||
| async transferImagesAction() { | |||
| const targetId = this.get('id'); | |||
| const type = this.get('type') || ''; | |||
| const model = this.model('spider_news'); | |||
| let list; | |||
| let where = { status: 1 }; | |||
| if (targetId) { | |||
| list = await model.where({ origin_id: targetId }).select(); | |||
| } else { | |||
| list = await model.where({ status: 1 }).select(); | |||
| where = { origin_id: targetId }; | |||
| if (type) where.type = type; | |||
| } | |||
| if (type && !targetId) { | |||
| where.type = type; | |||
| } | |||
| const list = await model.where(where).select(); | |||
| if (!list.length) { | |||
| return this.json({ code: 0, msg: '没有待转存图片的记录' }); | |||
| } | |||
| @@ -54,6 +54,9 @@ module.exports = class extends Base { | |||
| const id = await model.add({ | |||
| parent_id: data.parent_id || 0, | |||
| name: data.name, | |||
| name_en: data.name_en || '', | |||
| description: data.description || '', | |||
| banner_image: data.banner_image || '', | |||
| key: data.key || '', | |||
| icon: data.icon || '', | |||
| type: data.type || '', | |||
| @@ -88,6 +91,9 @@ module.exports = class extends Base { | |||
| await this.model('column').where({ id }).update({ | |||
| parent_id: data.parent_id || 0, | |||
| name: data.name, | |||
| name_en: data.name_en || '', | |||
| description: data.description || '', | |||
| banner_image: data.banner_image || '', | |||
| key: data.key || '', | |||
| icon: data.icon || '', | |||
| type: data.type || '', | |||
| @@ -12,6 +12,7 @@ const WHITE_LIST = [ | |||
| '/admin/logout', | |||
| // 爬虫工具 | |||
| '/admin/spider/fetch-list', | |||
| '/admin/spider/fetch-about-list', | |||
| '/admin/spider/fetch-detail', | |||
| '/admin/spider/transfer-images', | |||
| '/admin/spider/sync', | |||
| @@ -49,8 +50,9 @@ module.exports = class extends think.Controller { | |||
| } | |||
| } | |||
| // 白名单放行 | |||
| if (WHITE_LIST.includes(path)) { | |||
| // 白名单放行(同时匹配带.html后缀的路径) | |||
| const pathNoSuffix = path.replace(/\.html$/, ''); | |||
| if (WHITE_LIST.includes(path) || WHITE_LIST.includes(pathNoSuffix)) { | |||
| return; | |||
| } | |||
| @@ -0,0 +1,112 @@ | |||
| const Base = require('./base.js'); | |||
| module.exports = class extends Base { | |||
| async indexAction() { | |||
| const columnKey = this.get('key'); | |||
| const childKey = this.get('child'); | |||
| if (!columnKey) return this.redirect('/'); | |||
| // 获取所有栏目 | |||
| const columns = await this.model('column').where({ is_deleted: 0, visible: 1 }).order('sort ASC').select(); | |||
| const navMenus = this.buildNavTree(columns); | |||
| // 找到当前一级栏目 | |||
| const column = columns.find(c => c.key === columnKey && c.parent_id === 0); | |||
| if (!column) return this.redirect('/'); | |||
| // 获取子栏目 | |||
| const children = columns.filter(c => c.parent_id === column.id).sort((a, b) => a.sort - b.sort); | |||
| // 确定当前选中的子栏目 | |||
| let currentChild = null; | |||
| if (childKey) { | |||
| currentChild = children.find(c => c.key === childKey); | |||
| } | |||
| if (!currentChild && children.length > 0) { | |||
| currentChild = children[0]; | |||
| } | |||
| // 根据子栏目类型加载内容 | |||
| let contentData = {}; | |||
| if (currentChild) { | |||
| contentData = await this.loadContent(currentChild); | |||
| } | |||
| // 网站配置 | |||
| const configList = await this.model('config').select(); | |||
| const siteConfig = {}; | |||
| configList.forEach(c => { siteConfig[c.key] = c.value; }); | |||
| this.assign({ | |||
| navMenus, | |||
| column, | |||
| children, | |||
| currentChild, | |||
| contentData, | |||
| siteConfig, | |||
| currentColumnKey: columnKey | |||
| }); | |||
| return this.display('column_index'); | |||
| } | |||
| // 根据类型加载内容 | |||
| async loadContent(child) { | |||
| const colId = child.id; | |||
| const type = child.type; | |||
| switch (type) { | |||
| case 'page': { | |||
| const page = await this.model('page').where({ column_id: colId, is_deleted: 0 }).find(); | |||
| return { type: 'page', page: page || {} }; | |||
| } | |||
| case 'article': { | |||
| const list = await this.model('article').where({ column_id: colId, status: 1, is_deleted: 0 }) | |||
| .order('is_top DESC, sort DESC, publish_time DESC').limit(20).select(); | |||
| // 格式化日期 | |||
| list.forEach(item => { | |||
| if (item.publish_time) { | |||
| const d = new Date(item.publish_time); | |||
| item.publish_date = d.getFullYear() + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.' + String(d.getDate()).padStart(2, '0'); | |||
| } | |||
| }); | |||
| return { type: 'article', list }; | |||
| } | |||
| case 'image': { | |||
| const list = await this.model('image').where({ column_id: colId, status: 1, is_deleted: 0 }) | |||
| .order('sort ASC').select(); | |||
| return { type: 'image', list }; | |||
| } | |||
| case 'text': { | |||
| const list = await this.model('text').where({ column_id: colId, status: 1, is_deleted: 0 }) | |||
| .order('sort ASC, id DESC').select(); | |||
| return { type: 'text', list }; | |||
| } | |||
| case 'person': { | |||
| const list = await this.model('person').where({ column_id: colId, status: 1, is_deleted: 0 }) | |||
| .order('sort ASC').select(); | |||
| return { type: 'person', list }; | |||
| } | |||
| case 'job': { | |||
| const list = await this.model('job').where({ column_id: colId, status: 1, is_deleted: 0 }) | |||
| .order('sort ASC, id DESC').select(); | |||
| return { type: 'job', list }; | |||
| } | |||
| default: | |||
| return { type: type || 'empty' }; | |||
| } | |||
| } | |||
| // 构建导航树 | |||
| buildNavTree(columns) { | |||
| const tree = []; | |||
| const map = {}; | |||
| columns.forEach(item => { map[item.id] = { ...item, children: [] }; }); | |||
| columns.forEach(item => { | |||
| if (item.parent_id === 0) tree.push(map[item.id]); | |||
| else if (map[item.parent_id]) map[item.parent_id].children.push(map[item.id]); | |||
| }); | |||
| return tree; | |||
| } | |||
| }; | |||
| @@ -1,10 +1,26 @@ | |||
| const Base = require('./base.js'); | |||
| // 首页数据内存缓存 | |||
| let homeCache = null; | |||
| let homeCacheTime = 0; | |||
| const CACHE_TTL = 60 * 1000; // 60秒缓存 | |||
| module.exports = class extends Base { | |||
| async indexAction() { | |||
| const t0 = Date.now(); | |||
| // 检查缓存 | |||
| if (homeCache && (Date.now() - homeCacheTime < CACHE_TTL)) { | |||
| console.log(`[perf] cache hit, age: ${Date.now() - homeCacheTime}ms`); | |||
| this.assign(homeCache); | |||
| return this.display(); | |||
| } | |||
| // 获取栏目配置 | |||
| const columns = await this.model('column').where({ is_deleted: 0, visible: 1 }).order('sort ASC').select(); | |||
| const t1 = Date.now(); | |||
| console.log(`[perf] columns: ${t1 - t0}ms`); | |||
| // 构建导航菜单树 | |||
| const navMenus = this.buildNavTree(columns); | |||
| @@ -14,71 +30,76 @@ module.exports = class extends Base { | |||
| const colMap = {}; | |||
| homeChildren.forEach(c => { colMap[c.key] = c.id; }); | |||
| // 获取Banner数据(根据栏目key查找column_id,或者column_id=0表示通用) | |||
| const bannerColId = colMap['home-banner'] || 0; | |||
| const banners = await this.model('banner').where({ | |||
| status: 1, | |||
| is_deleted: 0 | |||
| }).where('column_id = ' + bannerColId + ' OR column_id = 0').order('sort ASC').select(); | |||
| // 获取捐赠统计 | |||
| const dataColId = colMap['home-data'] || 0; | |||
| let donationStat = await this.model('donation_stat').where({ | |||
| column_id: dataColId | |||
| }).find(); | |||
| const projectColId = colMap['home-project'] || 0; | |||
| const newsColId = colMap['home-news'] || 0; | |||
| const partnerColId = colMap['home-partner'] || 0; | |||
| const t2 = Date.now(); | |||
| // 并行查询所有数据 | |||
| const [ | |||
| banners, | |||
| donationStatResult, | |||
| donationIncome, | |||
| donationExpense, | |||
| medicines, | |||
| projects, | |||
| newsList, | |||
| partners, | |||
| configList | |||
| ] = await Promise.all([ | |||
| this.model('banner').where({ status: 1, is_deleted: 0 }) | |||
| .where('column_id = ' + bannerColId + ' OR column_id = 0').order('sort ASC').select(), | |||
| this.model('donation_stat').where({ column_id: dataColId }).find(), | |||
| this.model('donation').where({ type: 'income', status: 1, is_deleted: 0 }) | |||
| .where('column_id = ' + dataColId + ' OR column_id = 0').order('record_date DESC').limit(10).select(), | |||
| this.model('donation').where({ type: 'expense', status: 1, is_deleted: 0 }) | |||
| .where('column_id = ' + dataColId + ' OR column_id = 0').order('record_date DESC').limit(10).select(), | |||
| this.model('medicine').where({ status: 1, is_deleted: 0 }) | |||
| .order('distribute_date DESC').limit(20).select(), | |||
| this.model('article').where({ status: 1, is_deleted: 0 }) | |||
| .where('column_id = ' + projectColId + ' OR column_id = 0').order('sort DESC, publish_time DESC').limit(5).select(), | |||
| this.model('article').where({ status: 1, is_deleted: 0 }) | |||
| .where('column_id = ' + newsColId + ' OR column_id = 0').order('is_top DESC, publish_time DESC').limit(5).select(), | |||
| this.model('image').where({ status: 1, is_deleted: 0 }) | |||
| .where('column_id = ' + partnerColId + ' OR column_id = 0').order('sort ASC').select(), | |||
| this.model('config').select() | |||
| ]); | |||
| const t3 = Date.now(); | |||
| console.log(`[perf] parallel queries: ${t3 - t2}ms`); | |||
| // 捐赠统计兜底 | |||
| let donationStat = donationStatResult; | |||
| if (!donationStat || !donationStat.id) { | |||
| donationStat = await this.model('donation_stat').where({ column_id: 0 }).find(); | |||
| } | |||
| donationStat = donationStat || { total_income: 0, total_expense: 0, year_income: 0 }; | |||
| // 获取捐赠明细(收入/支出各取最新10条) | |||
| const donationIncome = await this.model('donation').where({ | |||
| type: 'income', | |||
| status: 1, | |||
| is_deleted: 0 | |||
| }).where('column_id = ' + dataColId + ' OR column_id = 0').order('record_date DESC').limit(10).select(); | |||
| const donationExpense = await this.model('donation').where({ | |||
| type: 'expense', | |||
| status: 1, | |||
| is_deleted: 0 | |||
| }).where('column_id = ' + dataColId + ' OR column_id = 0').order('record_date DESC').limit(10).select(); | |||
| // 获取药品援助数据(只显示已发放的) | |||
| const medicines = await this.model('medicine').where({ | |||
| status: 1, | |||
| is_deleted: 0 | |||
| }).order('distribute_date DESC').limit(20).select(); | |||
| // 获取公益项目 | |||
| const projectColId = colMap['home-project'] || 0; | |||
| const projects = await this.model('article').where({ | |||
| status: 1, | |||
| is_deleted: 0 | |||
| }).where('column_id = ' + projectColId + ' OR column_id = 0').order('sort DESC, publish_time DESC').limit(5).select(); | |||
| // 获取新闻动态 | |||
| const newsColId = colMap['home-news'] || 0; | |||
| const newsList = await this.model('article').where({ | |||
| status: 1, | |||
| is_deleted: 0 | |||
| }).where('column_id = ' + newsColId + ' OR column_id = 0').order('is_top DESC, publish_time DESC').limit(5).select(); | |||
| // 获取合作伙伴 | |||
| const partnerColId = colMap['home-partner'] || 0; | |||
| const partners = await this.model('image').where({ | |||
| status: 1, | |||
| is_deleted: 0 | |||
| }).where('column_id = ' + partnerColId + ' OR column_id = 0').order('sort ASC').select(); | |||
| // 获取网站配置 | |||
| const configList = await this.model('config').select(); | |||
| // 格式化日期 | |||
| const formatDate = (str) => { | |||
| if (!str) return { date: '', day: '', ym: '' }; | |||
| const d = new Date(str); | |||
| if (isNaN(d.getTime())) return { date: '', day: '', ym: '' }; | |||
| const y = d.getFullYear(); | |||
| const m = String(d.getMonth() + 1).padStart(2, '0'); | |||
| const dd = String(d.getDate()).padStart(2, '0'); | |||
| return { date: y + '.' + m + '.' + dd, day: dd, ym: y + '.' + m }; | |||
| }; | |||
| newsList.forEach(item => { | |||
| const fd = formatDate(item.publish_time); | |||
| item.publish_date = fd.date; | |||
| item.publish_day = fd.day; | |||
| item.publish_ym = fd.ym; | |||
| }); | |||
| // 网站配置 | |||
| const siteConfig = {}; | |||
| configList.forEach(c => { | |||
| siteConfig[c.key] = c.value; | |||
| }); | |||
| this.assign({ | |||
| const data = { | |||
| navMenus, | |||
| banners, | |||
| donationStat, | |||
| @@ -90,8 +111,16 @@ module.exports = class extends Base { | |||
| partners, | |||
| siteConfig, | |||
| now: new Date() | |||
| }); | |||
| }; | |||
| // 写入缓存 | |||
| homeCache = data; | |||
| homeCacheTime = Date.now(); | |||
| this.assign(data); | |||
| const t4 = Date.now(); | |||
| console.log(`[perf] TOTAL: ${t4 - t0}ms`); | |||
| return this.display(); | |||
| } | |||
| @@ -199,10 +199,10 @@ document.getElementById('loginForm').addEventListener('submit', async function(e | |||
| body: JSON.stringify({ username, password, remember }) | |||
| }); | |||
| const data = await res.json(); | |||
| if (data.errno === 0) { | |||
| if (data.code === 0) { | |||
| window.location.href = '/admin/dashboard.html'; | |||
| } else { | |||
| errorMsg.textContent = data.errmsg || '登录失败'; | |||
| errorMsg.textContent = data.msg || '登录失败'; | |||
| errorMsg.classList.add('show'); | |||
| } | |||
| } catch (err) { | |||
| @@ -46,6 +46,19 @@ | |||
| <el-form-item label="栏目名称" required> | |||
| <el-input v-model="form.name" placeholder="请输入栏目名称"></el-input> | |||
| </el-form-item> | |||
| <el-form-item label="英文名称" v-if="form.parent_id === 0"> | |||
| <el-input v-model="form.name_en" placeholder="如: ABOUT US"></el-input> | |||
| </el-form-item> | |||
| <el-form-item label="栏目描述" v-if="form.parent_id === 0"> | |||
| <el-input v-model="form.description" placeholder="一句话描述"></el-input> | |||
| </el-form-item> | |||
| <el-form-item label="Banner图" v-if="form.parent_id === 0"> | |||
| <div class="flex items-center gap-3"> | |||
| <el-image v-if="form.banner_image" :src="form.banner_image" style="width:200px;height:80px;border-radius:4px;" fit="cover"></el-image> | |||
| <el-button @click="uploadBanner">上传图片</el-button> | |||
| <el-button v-if="form.banner_image" link type="danger" @click="form.banner_image = ''">清除</el-button> | |||
| </div> | |||
| </el-form-item> | |||
| <el-form-item label="栏目标识"> | |||
| <el-input v-model="form.key" placeholder="如: about-us(用于路由)"></el-input> | |||
| </el-form-item> | |||
| @@ -112,6 +125,19 @@ | |||
| <el-form-item label="栏目名称" required> | |||
| <el-input v-model="form.name" placeholder="请输入栏目名称"></el-input> | |||
| </el-form-item> | |||
| <el-form-item label="英文名称" v-if="form.parent_id === 0"> | |||
| <el-input v-model="form.name_en" placeholder="如: ABOUT US"></el-input> | |||
| </el-form-item> | |||
| <el-form-item label="栏目描述" v-if="form.parent_id === 0"> | |||
| <el-input v-model="form.description" placeholder="一句话描述"></el-input> | |||
| </el-form-item> | |||
| <el-form-item label="Banner图" v-if="form.parent_id === 0"> | |||
| <div class="flex items-center gap-3"> | |||
| <el-image v-if="form.banner_image" :src="form.banner_image" style="width:200px;height:80px;border-radius:4px;" fit="cover"></el-image> | |||
| <el-button @click="uploadBanner">上传图片</el-button> | |||
| <el-button v-if="form.banner_image" link type="danger" @click="form.banner_image = ''">清除</el-button> | |||
| </div> | |||
| </el-form-item> | |||
| <el-form-item label="栏目标识"> | |||
| <el-input v-model="form.key" placeholder="如: about-us(用于路由)"></el-input> | |||
| </el-form-item> | |||
| @@ -288,7 +314,7 @@ const app = createApp({ | |||
| const saving = ref(false); | |||
| const parentIsSinglePage = ref(false); | |||
| const form = reactive({ | |||
| id: null, parent_id: 0, name: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1, | |||
| id: null, parent_id: 0, name: '', name_en: '', description: '', banner_image: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1, | |||
| link: '', seo_title: '', seo_keywords: '', seo_description: '', slug: '' | |||
| }); | |||
| @@ -304,6 +330,25 @@ const app = createApp({ | |||
| iconPickerVisible.value = false; | |||
| } | |||
| // Banner图片上传 | |||
| function uploadBanner() { | |||
| const input = document.createElement('input'); | |||
| input.type = 'file'; | |||
| input.accept = 'image/*'; | |||
| input.onchange = async (e) => { | |||
| const file = e.target.files[0]; | |||
| if (!file) return; | |||
| const fd = new FormData(); | |||
| fd.append('file', file); | |||
| try { | |||
| const res = await fetch('/admin/upload', { method: 'POST', body: fd }).then(r => r.json()); | |||
| if (res.code === 0) { form.banner_image = res.data.url; } | |||
| else { ElementPlus.ElMessage.error(res.msg || '上传失败'); } | |||
| } catch { ElementPlus.ElMessage.error('上传失败'); } | |||
| }; | |||
| input.click(); | |||
| } | |||
| const showSeoTab = computed(() => { | |||
| if (form.parent_id === 0 && form.is_single_page === 1) return true; | |||
| if (form.parent_id !== 0 && !parentIsSinglePage.value) return true; | |||
| @@ -338,7 +383,7 @@ const app = createApp({ | |||
| activeTab.value = 'basic'; | |||
| parentIsSinglePage.value = parentData ? !!parentData.is_single_page : false; | |||
| Object.assign(form, { | |||
| id: null, parent_id: parentId || 0, name: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1, | |||
| id: null, parent_id: parentId || 0, name: '', name_en: '', description: '', banner_image: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1, | |||
| link: '', seo_title: '', seo_keywords: '', seo_description: '', slug: '' | |||
| }); | |||
| dialogVisible.value = true; | |||
| @@ -354,7 +399,8 @@ const app = createApp({ | |||
| parentIsSinglePage.value = false; | |||
| } | |||
| Object.assign(form, { | |||
| id: row.id, parent_id: row.parent_id || 0, name: row.name, key: row.key || '', icon: row.icon || '', | |||
| id: row.id, parent_id: row.parent_id || 0, name: row.name, name_en: row.name_en || '', description: row.description || '', | |||
| banner_image: row.banner_image || '', key: row.key || '', icon: row.icon || '', | |||
| type: row.type || '', is_single_page: row.is_single_page || 0, sort: row.sort || 1, visible: row.visible, | |||
| link: row.link || '', seo_title: row.seo_title || '', seo_keywords: row.seo_keywords || '', | |||
| seo_description: row.seo_description || '', slug: row.slug || '' | |||
| @@ -412,7 +458,8 @@ const app = createApp({ | |||
| iconPickerVisible, iconSearch, filteredIcons, selectIcon, | |||
| formDrawerVisible, formDrawerTitle, formFields, formSaving, | |||
| loadList, toggleAllTree, showAddModal, editItem, saveItem, deleteItem, | |||
| openFormConfig, addFormField, moveField, removeField, saveFormConfig | |||
| openFormConfig, addFormField, moveField, removeField, saveFormConfig, | |||
| uploadBanner | |||
| }; | |||
| } | |||
| }); | |||
| @@ -0,0 +1,26 @@ | |||
| <!-- 图文列表 --> | |||
| <div class="col-article-list"> | |||
| {% if contentData.list and contentData.list.length > 0 %} | |||
| {% for item in contentData.list %} | |||
| <a href="/article/{{item.id}}.html" class="col-article-item"> | |||
| {% if item.cover %} | |||
| <div class="col-article-cover"> | |||
| <img src="{{item.cover}}" alt="{{item.title}}"> | |||
| </div> | |||
| {% endif %} | |||
| <div class="col-article-info"> | |||
| <h3>{{item.title}}</h3> | |||
| {% if item.summary %} | |||
| <p>{{item.summary}}</p> | |||
| {% endif %} | |||
| <div class="col-article-meta"> | |||
| {% if item.publish_date %}<span class="date">{{item.publish_date}}</span>{% endif %} | |||
| {% if item.category %}<span class="cate">{{item.category}}</span>{% endif %} | |||
| </div> | |||
| </div> | |||
| </a> | |||
| {% endfor %} | |||
| {% else %} | |||
| <div class="col-empty">暂无内容</div> | |||
| {% endif %} | |||
| </div> | |||
| @@ -0,0 +1,19 @@ | |||
| <!-- 图片列表 --> | |||
| <div class="col-image-grid"> | |||
| {% if contentData.list and contentData.list.length > 0 %} | |||
| {% for item in contentData.list %} | |||
| <div class="col-image-item"> | |||
| <div class="col-image-thumb"> | |||
| {% if item.link %} | |||
| <a href="{{item.link}}" target="_blank"><img src="{{item.image}}" alt="{{item.title}}"></a> | |||
| {% else %} | |||
| <img src="{{item.image}}" alt="{{item.title}}"> | |||
| {% endif %} | |||
| </div> | |||
| {% if item.title %}<div class="col-image-title">{{item.title}}</div>{% endif %} | |||
| </div> | |||
| {% endfor %} | |||
| {% else %} | |||
| <div class="col-empty">暂无内容</div> | |||
| {% endif %} | |||
| </div> | |||
| @@ -0,0 +1,16 @@ | |||
| <!-- 岗位列表 --> | |||
| <div class="col-job-list"> | |||
| {% if contentData.list and contentData.list.length > 0 %} | |||
| {% for item in contentData.list %} | |||
| <div class="col-job-item"> | |||
| <div class="col-job-header"> | |||
| <h3>{{item.title}}</h3> | |||
| {% if item.location %}<span class="col-job-loc">{{item.location}}</span>{% endif %} | |||
| </div> | |||
| {% if item.description %}<p class="col-job-desc">{{item.description}}</p>{% endif %} | |||
| </div> | |||
| {% endfor %} | |||
| {% else %} | |||
| <div class="col-empty">暂无招聘信息</div> | |||
| {% endif %} | |||
| </div> | |||
| @@ -0,0 +1,8 @@ | |||
| <!-- 单页内容 --> | |||
| <div class="col-page"> | |||
| {% if contentData.page and contentData.page.content %} | |||
| <div class="col-page-body">{{contentData.page.content | safe}}</div> | |||
| {% else %} | |||
| <div class="col-empty">暂无内容</div> | |||
| {% endif %} | |||
| </div> | |||
| @@ -0,0 +1,24 @@ | |||
| <!-- 人员列表 --> | |||
| <div class="col-person-grid"> | |||
| {% if contentData.list and contentData.list.length > 0 %} | |||
| {% for item in contentData.list %} | |||
| <div class="col-person-card"> | |||
| <div class="col-person-photo"> | |||
| {% if item.avatar %} | |||
| <img src="{{item.avatar}}" alt="{{item.name}}"> | |||
| {% else %} | |||
| <div class="col-person-placeholder">{{item.name[0] | default('?')}}</div> | |||
| {% endif %} | |||
| </div> | |||
| <div class="col-person-info"> | |||
| <div class="col-person-name">{{item.name}}</div> | |||
| {% if item.title %}<div class="col-person-title">{{item.title}}</div>{% endif %} | |||
| {% if item.description %}<div class="col-person-bio">{{item.description}}</div>{% endif %} | |||
| {% if item.category %}<div class="col-person-cate">{{item.category}}</div>{% endif %} | |||
| </div> | |||
| </div> | |||
| {% endfor %} | |||
| {% else %} | |||
| <div class="col-empty">暂无内容</div> | |||
| {% endif %} | |||
| </div> | |||
| @@ -0,0 +1,19 @@ | |||
| <!-- 文字列表 --> | |||
| <div class="col-text-list"> | |||
| {% if contentData.list and contentData.list.length > 0 %} | |||
| {% for item in contentData.list %} | |||
| <div class="col-text-item"> | |||
| <div class="col-text-title"> | |||
| {% if item.attachment %} | |||
| <a href="{{item.attachment}}" target="_blank">{{item.title}}</a> | |||
| {% else %} | |||
| <span>{{item.title}}</span> | |||
| {% endif %} | |||
| </div> | |||
| {% if item.publish_date %}<div class="col-text-date">{{item.publish_date}}</div>{% endif %} | |||
| </div> | |||
| {% endfor %} | |||
| {% else %} | |||
| <div class="col-empty">暂无内容</div> | |||
| {% endif %} | |||
| </div> | |||
| @@ -0,0 +1,71 @@ | |||
| {% extends "layout.html" %} | |||
| {% block title %}{{column.name}} - {{siteConfig.site_name}}{% endblock %} | |||
| {% block content %} | |||
| <!-- Column Banner --> | |||
| <div class="col-banner" {% if column.banner_image %}style="background-image:url('{{column.banner_image}}')"{% endif %}> | |||
| <div class="col-banner-overlay"></div> | |||
| <div class="col-banner-content"> | |||
| <h1>{{column.name}}</h1> | |||
| {% if column.name_en %} | |||
| <div class="col-banner-en">{{column.name_en}}</div> | |||
| {% endif %} | |||
| {% if column.description %} | |||
| <div class="col-banner-desc">{{column.description}}</div> | |||
| {% endif %} | |||
| </div> | |||
| </div> | |||
| <!-- Sub Navigation Tabs --> | |||
| {% if children and children.length > 0 %} | |||
| <div class="col-tabs-wrap"> | |||
| <div class="col-tabs"> | |||
| {% for child in children %} | |||
| <a href="/column/{{column.key}}/{{child.key}}.html" | |||
| class="col-tab{% if currentChild and currentChild.key == child.key %} active{% endif %}"> | |||
| {{child.name}} | |||
| </a> | |||
| {% endfor %} | |||
| </div> | |||
| </div> | |||
| {% endif %} | |||
| <!-- Breadcrumb --> | |||
| <div class="col-breadcrumb-wrap"> | |||
| <div class="col-breadcrumb"> | |||
| <span>您的位置:</span> | |||
| <a href="/">首页</a><span class="sep">></span> | |||
| <a href="/column/{{column.key}}.html">{{column.name}}</a> | |||
| {% if currentChild %} | |||
| <span class="sep">></span><span class="current">{{currentChild.name}}</span> | |||
| {% endif %} | |||
| </div> | |||
| </div> | |||
| <!-- Content Area --> | |||
| <div class="col-content-wrap"> | |||
| <div class="col-content"> | |||
| {% if currentChild %} | |||
| <h2 class="col-section-title">{{currentChild.name}}</h2> | |||
| {% endif %} | |||
| {% if contentData.type == 'page' %} | |||
| {% include "column/_page.html" %} | |||
| {% elif contentData.type == 'article' %} | |||
| {% include "column/_article.html" %} | |||
| {% elif contentData.type == 'image' %} | |||
| {% include "column/_image.html" %} | |||
| {% elif contentData.type == 'text' %} | |||
| {% include "column/_text.html" %} | |||
| {% elif contentData.type == 'person' %} | |||
| {% include "column/_person.html" %} | |||
| {% elif contentData.type == 'job' %} | |||
| {% include "column/_job.html" %} | |||
| {% else %} | |||
| <div class="col-empty">暂无内容</div> | |||
| {% endif %} | |||
| </div> | |||
| </div> | |||
| {% include "common/_footer.html" %} | |||
| {% endblock %} | |||
| @@ -35,7 +35,7 @@ | |||
| <div class="footer-links"> | |||
| {% for menu in navMenus %} | |||
| {% if loop.index <= 6 %} | |||
| <a href="{% if menu.key == 'home' %}/{% else %}/{{menu.key}}.html{% endif %}">{{menu.name}}</a> | |||
| <a href="{% if menu.key == 'home' %}/{% else %}/column/{{menu.key}}.html{% endif %}">{{menu.name}}</a> | |||
| {% if loop.index < 6 and loop.index < navMenus.length %} | |||
| <span class="separator">|</span> | |||
| {% endif %} | |||
| @@ -9,15 +9,15 @@ | |||
| {% for menu in navMenus %} | |||
| {% if menu.children and menu.children.length > 0 and not menu.is_single_page %} | |||
| <div class="nav-dropdown"> | |||
| <a href="{% if menu.link %}{{menu.link}}{% else %}javascript:;{% endif %}" class="nav-link">{{menu.name}}</a> | |||
| <a href="/column/{{menu.key}}.html" class="nav-link">{{menu.name}}</a> | |||
| <div class="dropdown-menu"> | |||
| {% for child in menu.children %} | |||
| <a href="/{{menu.key}}/{{child.key}}.html">{{child.name}}</a> | |||
| <a href="/column/{{menu.key}}/{{child.key}}.html">{{child.name}}</a> | |||
| {% endfor %} | |||
| </div> | |||
| </div> | |||
| {% else %} | |||
| <a href="{% if menu.key == 'home' %}/{% else %}/{{menu.key}}.html{% endif %}" class="nav-link{% if menu.key == 'home' %} active{% endif %}">{{menu.name}}</a> | |||
| <a href="{% if menu.key == 'home' %}/{% else %}/column/{{menu.key}}.html{% endif %}" class="nav-link{% if menu.key == 'home' %} active{% endif %}">{{menu.name}}</a> | |||
| {% endif %} | |||
| {% endfor %} | |||
| </nav> | |||
| @@ -44,19 +44,19 @@ | |||
| {% for menu in navMenus %} | |||
| {% if menu.children and menu.children.length > 0 and not menu.is_single_page %} | |||
| <div class="mobile-nav-item has-children"> | |||
| <a href="{% if menu.link %}{{menu.link}}{% else %}javascript:;{% endif %}" class="mobile-nav-link"> | |||
| <a href="/column/{{menu.key}}.html" class="mobile-nav-link"> | |||
| {{menu.name}} | |||
| <span class="mobile-nav-arrow">›</span> | |||
| </a> | |||
| <div class="mobile-nav-sub"> | |||
| {% for child in menu.children %} | |||
| <a href="/{{menu.key}}/{{child.key}}.html">{{child.name}}</a> | |||
| <a href="/column/{{menu.key}}/{{child.key}}.html">{{child.name}}</a> | |||
| {% endfor %} | |||
| </div> | |||
| </div> | |||
| {% else %} | |||
| <div class="mobile-nav-item"> | |||
| <a href="{% if menu.key == 'home' %}/{% else %}/{{menu.key}}.html{% endif %}" class="mobile-nav-link">{{menu.name}}</a> | |||
| <a href="{% if menu.key == 'home' %}/{% else %}/column/{{menu.key}}.html{% endif %}" class="mobile-nav-link">{{menu.name}}</a> | |||
| </div> | |||
| {% endif %} | |||
| {% endfor %} | |||
| @@ -77,12 +77,14 @@ | |||
| <div class="dtable"> | |||
| <h3>捐赠收入明细</h3> | |||
| <div class="dtable-table-wrap"> | |||
| <table> | |||
| <thead><tr><th>捐赠方</th><th class="td-r" style="width:120px;">金额</th></tr></thead> | |||
| <table class="dtable-head"> | |||
| <colgroup><col><col style="width:140px;"></colgroup> | |||
| <thead><tr><th>捐赠方</th><th class="td-r">金额</th></tr></thead> | |||
| </table> | |||
| <div class="dtable-scroll-wrap"> | |||
| <div class="dtable-scroll-wrap donation-scroll"> | |||
| <div class="dtable-scroll-inner"> | |||
| <table> | |||
| <colgroup><col><col style="width:140px;"></colgroup> | |||
| <tbody> | |||
| {% for item in donationIncome %} | |||
| <tr><td>{{item.name}}</td><td class="td-r">{{item.amount}}</td></tr> | |||
| @@ -99,12 +101,14 @@ | |||
| <div class="dtable"> | |||
| <h3>公益支出明细</h3> | |||
| <div class="dtable-table-wrap"> | |||
| <table> | |||
| <thead><tr><th>支付对象</th><th class="td-r" style="width:120px;">金额</th></tr></thead> | |||
| <table class="dtable-head"> | |||
| <colgroup><col><col style="width:140px;"></colgroup> | |||
| <thead><tr><th>支付对象</th><th class="td-r">金额</th></tr></thead> | |||
| </table> | |||
| <div class="dtable-scroll-wrap"> | |||
| <div class="dtable-scroll-wrap donation-scroll"> | |||
| <div class="dtable-scroll-inner"> | |||
| <table> | |||
| <colgroup><col><col style="width:140px;"></colgroup> | |||
| <tbody> | |||
| {% for item in donationExpense %} | |||
| <tr><td>{{item.name}}</td><td class="td-r">{{item.amount}}</td></tr> | |||
| @@ -198,7 +202,7 @@ | |||
| <img src="{{newsList[0].cover}}" alt="{{newsList[0].title}}"> | |||
| <div class="overlay"> | |||
| <h3>{{newsList[0].title}}</h3> | |||
| <div class="date">{{newsList[0].publish_time}}</div> | |||
| <div class="date">{{newsList[0].publish_date}}</div> | |||
| </div> | |||
| </a> | |||
| {% endif %} | |||
| @@ -207,8 +211,8 @@ | |||
| {% if loop.index > 1 %} | |||
| <a href="/news/detail/{{news.id}}.html" class="news-item"> | |||
| <div class="date-box"> | |||
| <div class="day">{{loop.index}}</div> | |||
| <div class="ym">{{news.publish_time}}</div> | |||
| <div class="day">{{news.publish_day}}</div> | |||
| <div class="ym">{{news.publish_ym}}</div> | |||
| </div> | |||
| <div class="text"> | |||
| <h4>{{news.title}}</h4> | |||
| @@ -41,18 +41,18 @@ img{max-width:100%;display:block;} | |||
| position:relative;padding:8px 18px;font-size:15px;font-weight:500; | |||
| color:var(--gray-700);letter-spacing:1px;transition:.25s;border-radius:4px; | |||
| } | |||
| .nav-link:hover,.nav-link.active{color:var(--orange);background:rgba(232,117,26,.08);} | |||
| .nav-link:hover,.nav-link.active{color:var(--orange);} | |||
| /* Dropdown */ | |||
| .nav-dropdown{position:relative;} | |||
| .nav-dropdown .dropdown-menu{ | |||
| position:absolute;top:100%;left:50%;transform:translateX(-50%) translateY(8px); | |||
| position:absolute;top:100%;left:50%;transform:translateX(-50%) translateY(4px); | |||
| min-width:160px;background:rgba(255,255,255,.98);backdrop-filter:blur(12px);border-radius:8px; | |||
| box-shadow:0 8px 32px rgba(0,0,0,.12);padding:8px 0;border:1px solid rgba(0,0,0,.06); | |||
| opacity:0;visibility:hidden;transition:.25s;pointer-events:none; | |||
| } | |||
| .nav-dropdown:hover .dropdown-menu{opacity:1;visibility:visible;transform:translateX(-50%) translateY(0);pointer-events:auto;} | |||
| .dropdown-menu a{display:block;padding:10px 24px;font-size:14px;color:var(--gray-700);white-space:nowrap;transition:.15s;} | |||
| .dropdown-menu a{display:block;padding:10px 24px;font-size:14px;color:var(--gray-700);white-space:nowrap;transition:.15s;text-align:center;} | |||
| .dropdown-menu a:hover{color:var(--orange);background:rgba(232,117,26,.04);} | |||
| .header-donate{ | |||
| @@ -265,11 +265,11 @@ img{max-width:100%;display:block;} | |||
| .dtable th{text-align:center;padding:5px 8px;color:var(--gray-500);font-weight:500;border-bottom:1px solid var(--gray-100);} | |||
| .dtable td{text-align:center;padding:5px 8px;color:var(--gray-700);border-bottom:1px solid var(--gray-100);} | |||
| .dtable .td-r{font-family:var(--font-en);font-weight:500;color:var(--orange);} | |||
| .dtable-head{margin-bottom:0;} | |||
| .dtable-scroll-wrap{max-height:140px;overflow:hidden;position:relative;} | |||
| .dtable-scroll-inner{animation:dtableScroll 12s cubic-bezier(.45,.05,.55,.95) infinite;} | |||
| .dtable:nth-child(2) .dtable-scroll-inner{animation-delay:1.5s;} | |||
| .dtable-scroll-wrap:hover .dtable-scroll-inner{animation-play-state:paused;} | |||
| @keyframes dtableScroll{0%,15%{transform:translateY(0);}50%,65%{transform:translateY(-50%);}100%{transform:translateY(0);}} | |||
| .dtable-scroll-wrap.donation-scroll .dtable-scroll-inner{animation:donationScroll 15s linear infinite;} | |||
| .dtable-scroll-wrap.donation-scroll:hover .dtable-scroll-inner{animation-play-state:paused;} | |||
| @keyframes donationScroll{0%{transform:translateY(0);}100%{transform:translateY(-50%);}} | |||
| .dtable-drug{grid-column:1/-1;} | |||
| .dtable-drug h3{border-bottom-color:var(--orange);} | |||
| @@ -354,9 +354,9 @@ img{max-width:100%;display:block;} | |||
| } | |||
| .swiper-slide-active .news-wrap{opacity:1;transform:translateY(0);} | |||
| .news-grid{display:grid;grid-template-columns:1fr 1fr;gap:36px;align-items:start;} | |||
| .news-featured{border-radius:14px;overflow:hidden;position:relative;cursor:pointer;} | |||
| .news-featured img{width:100%;height:clamp(280px,40vh,400px);object-fit:cover;transition:.5s;} | |||
| .news-grid{display:grid;grid-template-columns:1fr 1fr;gap:36px;align-items:stretch;} | |||
| .news-featured{border-radius:14px;overflow:hidden;position:relative;cursor:pointer;display:flex;} | |||
| .news-featured img{width:100%;height:100%;object-fit:cover;transition:.5s;} | |||
| .news-featured:hover img{transform:scale(1.03);} | |||
| .news-featured .overlay{ | |||
| position:absolute;bottom:0;left:0;right:0; | |||
| @@ -374,8 +374,8 @@ img{max-width:100%;display:block;} | |||
| } | |||
| .news-item:hover{padding-left:8px;} | |||
| .news-item .date-box{flex-shrink:0;width:54px;text-align:center;} | |||
| .news-item .date-box .day{font-family:var(--font-en);font-size:24px;font-weight:700;color:var(--orange);line-height:1;} | |||
| .news-item .date-box .ym{font-size:12px;color:var(--gray-300);} | |||
| .news-item .date-box .day{font-family:var(--font-en);font-size:28px;font-weight:700;color:var(--orange);line-height:1;} | |||
| .news-item .date-box .ym{font-size:12px;color:var(--gray-300);white-space:nowrap;margin-top:2px;} | |||
| .news-item .text h4{font-size:14px;font-weight:500;color:var(--gray-900);margin-bottom:4px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;} | |||
| .news-item .text p{font-size:13px;color:var(--gray-500);display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;} | |||
| @@ -537,7 +537,7 @@ img{max-width:100%;display:block;} | |||
| .proj-info-card .card-btn{padding:8px 20px;font-size:12px;} | |||
| .proj-tabs{bottom:8px;left:8px;right:8px;} | |||
| .proj-tab{padding:5px 8px;font-size:11px;} | |||
| .news-item .date-box .day{font-size:20px;} | |||
| .news-item .date-box .day{font-size:22px;} | |||
| .news-item .text h4{font-size:13px;} | |||
| .partners-grid{grid-template-columns:repeat(2,1fr);gap:8px;} | |||
| .partner-item{padding:12px;border-radius:8px;min-height:70px;} | |||
| @@ -552,3 +552,187 @@ img{max-width:100%;display:block;} | |||
| .slide-donation{padding-top:56px;} | |||
| .slide-news{padding-top:56px;} | |||
| } | |||
| /* ===== Column Page ===== */ | |||
| .col-banner{ | |||
| position:relative;height:280px; | |||
| background:center/cover no-repeat var(--orange); | |||
| display:flex;align-items:center;justify-content:center; | |||
| margin-top:72px; | |||
| } | |||
| .col-banner-overlay{ | |||
| position:absolute;inset:0; | |||
| background:linear-gradient(180deg,rgba(0,0,0,.15) 0%,rgba(0,0,0,.4) 100%); | |||
| } | |||
| .col-banner-content{ | |||
| position:relative;z-index:1;text-align:center;color:#fff; | |||
| } | |||
| .col-banner-content h1{ | |||
| font-size:42px;font-weight:700;letter-spacing:2px;margin-bottom:12px; | |||
| } | |||
| .col-banner-en{ | |||
| font-family:var(--font-en);font-size:16px;letter-spacing:3px; | |||
| text-transform:uppercase;opacity:.9;margin-bottom:16px; | |||
| } | |||
| .col-banner-desc{ | |||
| font-size:18px;opacity:.95;letter-spacing:2px; | |||
| } | |||
| /* Sub Tabs */ | |||
| .col-tabs-wrap{ | |||
| background:var(--gray-bg);border-bottom:none; | |||
| position:sticky;top:72px;z-index:100;padding:12px 0; | |||
| } | |||
| .col-tabs{ | |||
| max-width:1200px;margin:0 auto;display:flex;justify-content:center; | |||
| gap:24px;align-items:center; | |||
| } | |||
| .col-tab{ | |||
| padding:6px 32px;font-size:16px;color:#606266; | |||
| border:none;border-radius:24px;transition:.3s;white-space:nowrap; | |||
| font-weight:500;background:transparent; | |||
| } | |||
| .col-tab:hover{color:#fff;background:rgba(232,117,26,.8);} | |||
| .col-tab.active{ | |||
| color:#fff;background:var(--orange);font-weight:600; | |||
| } | |||
| /* Breadcrumb */ | |||
| .col-breadcrumb-wrap{ | |||
| background:#fff;border-bottom:none; | |||
| } | |||
| .col-breadcrumb{ | |||
| max-width:1200px;margin:0 auto;padding:20px 0; | |||
| font-size:14px;color:#909399; | |||
| } | |||
| .col-breadcrumb a{color:#606266;transition:.2s;} | |||
| .col-breadcrumb a:hover{color:var(--orange);} | |||
| .col-breadcrumb .sep{margin:0 8px;color:#C0C4CC;} | |||
| .col-breadcrumb .current{color:var(--orange);} | |||
| /* Content Area */ | |||
| .col-content-wrap{background:#fff;min-height:50vh;} | |||
| .col-content{ | |||
| max-width:1200px;margin:0 auto;padding:40px; | |||
| } | |||
| .col-empty{ | |||
| text-align:center;padding:80px 0;color:var(--gray-300);font-size:15px; | |||
| } | |||
| /* Section Title */ | |||
| .col-section-title{ | |||
| font-size:28px;font-weight:700;color:var(--gray-900); | |||
| margin-bottom:32px;padding-bottom:16px; | |||
| border-bottom:3px solid var(--orange);display:inline-block; | |||
| } | |||
| /* Page Content */ | |||
| .col-page-body{line-height:2;font-size:15px;color:var(--gray-700);} | |||
| .col-page-body img{max-width:100%;height:auto;border-radius:8px;margin:16px 0;} | |||
| .col-page-body p{margin-bottom:16px;} | |||
| /* Article List */ | |||
| .col-article-list{display:flex;flex-direction:column;gap:24px;} | |||
| .col-article-item{ | |||
| display:flex;gap:24px;padding:20px 0; | |||
| border-bottom:1px solid var(--gray-100);transition:.2s; | |||
| } | |||
| .col-article-item:hover{padding-left:8px;} | |||
| .col-article-cover{flex-shrink:0;width:240px;height:160px;border-radius:8px;overflow:hidden;} | |||
| .col-article-cover img{width:100%;height:100%;object-fit:cover;transition:.3s;} | |||
| .col-article-item:hover .col-article-cover img{transform:scale(1.05);} | |||
| .col-article-info{flex:1;display:flex;flex-direction:column;justify-content:center;} | |||
| .col-article-info h3{font-size:18px;font-weight:600;color:var(--gray-900);margin-bottom:8px;line-height:1.5;} | |||
| .col-article-info p{font-size:14px;color:var(--gray-500);line-height:1.8;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;} | |||
| .col-article-meta{margin-top:auto;padding-top:12px;display:flex;gap:16px;font-size:13px;color:var(--gray-300);} | |||
| .col-article-meta .cate{color:var(--orange);} | |||
| /* Image Grid (Certificate Style) */ | |||
| .col-image-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;} | |||
| .col-image-item{ | |||
| background:#fff;border-radius:8px;overflow:hidden; | |||
| box-shadow:0 4px 16px rgba(0,0,0,.08);transition:.3s;cursor:pointer; | |||
| } | |||
| .col-image-item:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(232,117,26,.15);} | |||
| .col-image-thumb{ | |||
| width:100%;height:320px;background:var(--gray-bg); | |||
| display:flex;align-items:center;justify-content:center;overflow:hidden; | |||
| } | |||
| .col-image-thumb img{width:100%;height:100%;object-fit:contain;padding:16px;} | |||
| .col-image-title{ | |||
| padding:16px;text-align:center;font-size:14px;color:var(--gray-900);background:#fff; | |||
| } | |||
| /* Text List */ | |||
| .col-text-list{display:flex;flex-direction:column;} | |||
| .col-text-item{ | |||
| display:flex;align-items:center;justify-content:space-between; | |||
| padding:16px 0;border-bottom:1px solid var(--gray-100);transition:.2s; | |||
| } | |||
| .col-text-item:hover{padding-left:8px;} | |||
| .col-text-title{font-size:15px;color:var(--gray-700);} | |||
| .col-text-title a{color:var(--gray-700);transition:.2s;} | |||
| .col-text-title a:hover{color:var(--orange);} | |||
| .col-text-date{font-size:13px;color:var(--gray-300);flex-shrink:0;margin-left:24px;} | |||
| /* Person Grid (Team Member Card Style) */ | |||
| .col-person-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:32px;} | |||
| .col-person-card{ | |||
| background:#fff;border-radius:12px;overflow:hidden; | |||
| box-shadow:0 4px 16px rgba(0,0,0,.08);transition:.3s; | |||
| } | |||
| .col-person-card:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(232,117,26,.15);} | |||
| .col-person-photo{ | |||
| width:100%;height:280px;background:var(--gray-bg); | |||
| display:flex;align-items:center;justify-content:center;overflow:hidden; | |||
| } | |||
| .col-person-photo img{width:100%;height:100%;object-fit:cover;} | |||
| .col-person-placeholder{ | |||
| width:100%;height:100%;display:flex;align-items:center;justify-content:center; | |||
| background:var(--orange);color:#fff;font-size:48px;font-weight:700; | |||
| } | |||
| .col-person-info{padding:24px;} | |||
| .col-person-name{font-size:18px;font-weight:600;color:var(--gray-900);margin-bottom:8px;} | |||
| .col-person-title{font-size:14px;color:var(--orange);margin-bottom:12px;} | |||
| .col-person-bio{font-size:14px;color:#606266;line-height:1.8;} | |||
| .col-person-cate{font-size:12px;color:var(--orange);margin-top:4px;} | |||
| /* Job List */ | |||
| .col-job-list{display:flex;flex-direction:column;gap:16px;} | |||
| .col-job-item{ | |||
| padding:24px;border:1px solid var(--gray-100);border-radius:8px;transition:.2s; | |||
| } | |||
| .col-job-item:hover{border-color:var(--orange);box-shadow:0 4px 16px rgba(232,117,26,.08);} | |||
| .col-job-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;} | |||
| .col-job-header h3{font-size:18px;font-weight:600;color:var(--gray-900);} | |||
| .col-job-loc{font-size:13px;color:var(--gray-500);background:var(--gray-bg);padding:4px 12px;border-radius:4px;} | |||
| .col-job-desc{font-size:14px;color:var(--gray-500);line-height:1.8;} | |||
| /* Column Page Responsive */ | |||
| @media(max-width:768px){ | |||
| .col-banner{height:220px;} | |||
| .col-banner-content h1{font-size:28px;} | |||
| .col-tabs-wrap{top:56px;} | |||
| .col-tabs{overflow-x:auto;justify-content:flex-start;padding:0 16px;-webkit-overflow-scrolling:touch;} | |||
| .col-tab{padding:6px 20px;font-size:14px;white-space:nowrap;} | |||
| .col-content{padding:24px 20px;} | |||
| .col-breadcrumb{padding:16px 20px;} | |||
| .col-article-cover{width:140px;height:100px;} | |||
| .col-article-info h3{font-size:15px;} | |||
| .col-image-grid{grid-template-columns:1fr;gap:16px;} | |||
| .col-image-thumb{height:280px;} | |||
| .col-person-grid{grid-template-columns:1fr;gap:16px;} | |||
| .col-person-photo{height:240px;} | |||
| } | |||
| @media(max-width:480px){ | |||
| .col-banner{height:180px;} | |||
| .col-banner-content h1{font-size:22px;} | |||
| .col-banner-en{font-size:12px;} | |||
| .col-tab{padding:5px 16px;font-size:13px;} | |||
| .col-content{padding:20px 16px;} | |||
| .col-article-item{flex-direction:column;gap:12px;} | |||
| .col-article-cover{width:100%;height:180px;} | |||
| .col-image-grid{grid-template-columns:1fr;gap:12px;} | |||
| .col-person-grid{grid-template-columns:1fr;gap:12px;} | |||
| } | |||